diff --git a/LuaRules/Configs/customcmds.lua b/LuaRules/Configs/customcmds.lua index 9538a4a60a..76539b3ddb 100644 --- a/LuaRules/Configs/customcmds.lua +++ b/LuaRules/Configs/customcmds.lua @@ -100,6 +100,17 @@ local commands = { FIELD_FAC_UNIT_TYPE = 38694, FIELD_FAC_QUEUELESS = 38695, + -- missile launcher (Missile Command Center widget; see Configs/missile_config.lua) + MISSILE_ZENITH = 39610, + MISSILE_TRINITY = 39611, + MISSILE_REEF = 39612, + MISSILE_SCYLLA = 39613, + MISSILE_EOS = 39614, + MISSILE_SEISMIC = 39615, + MISSILE_SHOCKLEY = 39616, + MISSILE_INFERNO = 39617, + MISSILE_ZENO = 39618, + -- terraform RAMP = 39734, LEVEL = 39736, diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 0b2fdc1cf1..d0e53bb021 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -1,5 +1,22 @@ local buildCmdFactory, buildCmdEconomy, buildCmdDefence, buildCmdSpecial, buildCmdUnits, cmdPosDef, factoryUnitPosDef = include("Configs/integral_menu_commands_processed.lua", nil, VFS.RAW_FIRST) +-- Launch tab layout, derived from the shared missile config so ids, positions and +-- tooltips live in one place (Configs/missile_config.lua) alongside the widget behaviour. +local missileConfig = include("Configs/missile_config.lua", nil, VFS.RAW_FIRST) +local missileCmds = {} +for _, m in ipairs(missileConfig) do + missileCmds[#missileCmds + 1] = {id = m.cmd, name = m.label, icon = m.unit, col = m.col, row = m.row, tooltip = m.tooltip} +end + +local missileCmdPos = {} +for _, missile in ipairs(missileCmds) do + missileCmdPos[missile.id] = {col = missile.col, row = missile.row} +end + +local function isMissileCommand(cmdID) + return missileCmdPos[cmdID] ~= nil +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Tooltips @@ -483,7 +500,55 @@ local factoryButtonLayoutOverride = { } } +for _, missile in ipairs(missileCmds) do + local unitDef = UnitDefNames[missile.icon] + local icon = unitDef and ("#" .. unitDef.id) or (imageDir .. 'Bold/attack.png') + commandDisplayConfig[missile.id] = { + texture = icon, + tooltip = missile.tooltip, + drawName = true, -- show the stockpile count / build progress string (set by the missile widget) + } +end + +-- Owning any launcher-relevant unit (a launcher, its ammo, or a silo) surfaces the tab. +local missileUnitNames = {["staticmissilesilo"] = true} +for _, m in ipairs(missileConfig) do + for _, l in ipairs(m.launch) do + missileUnitNames[l.unit] = true + end +end + +local function hasMissileUnits() + local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + for _, unitID in ipairs(teamUnits) do + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID then + local unitDef = UnitDefs[unitDefID] + if unitDef and missileUnitNames[unitDef.name] then + return true + end + end + end + return false +end + local commandPanels = { + { + humanName = "Launch", + name = "missiles", + inclusionFunction = function(cmdID) + if not hasMissileUnits() then return false end + local pos = missileCmdPos[cmdID] + return pos ~= nil, pos + end, + loiterable = true, + hiddenTab = true, + buttonLayoutConfig = buttonLayoutConfig.command, + gridHotkeys = true, + -- No returnOnClick: firing should not close the launcher, so multiple missiles + -- can be fired in a row. It is dismissed explicitly (right-click / select a + -- unit / toggle the launch button). + }, { humanName = "Orders", name = "orders", @@ -491,7 +556,8 @@ local commandPanels = { return ((cmdID >= 0 or unitMobilePanelSize == 1) and not buildCmdEconomy[cmdID] and not buildCmdFactory[cmdID] and not buildCmdSpecial[cmdID] and not buildCmdDefence[cmdID] and - not plateCommandID[cmdID]) + not plateCommandID[cmdID] and + not isMissileCommand(cmdID)) end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, diff --git a/LuaUI/Configs/missile_config.lua b/LuaUI/Configs/missile_config.lua new file mode 100644 index 0000000000..d250824485 --- /dev/null +++ b/LuaUI/Configs/missile_config.lua @@ -0,0 +1,85 @@ +-- Missile launcher configuration. Shared by the Missile Command Center widget +-- (launch/build behaviour) and integral_menu_config.lua (Launch tab layout), so a +-- missile type is described in exactly one place. One entry per launch button, in +-- display order. Command ids are assigned automatically from CMD_BASE below. +-- +-- Entry fields: +-- key - internal id (widget command table key). +-- unit - unit def name; supplies the button icon. +-- label - display name. +-- col, row - position in the Launch tab grid. +-- tooltip - button tooltip. +-- cmdType - "ICON_MAP" (default) or "ICON_UNIT_OR_MAP" (can target a unit). +-- siloBuild - unit this type is built as at a missile silo (enables Alt-click build). +-- zenith - true for the Zenith meteor controller (special, non-stockpile behaviour). +-- controllerScope - "separate": this button only exists when Eos/Scylla are NOT combined. +-- launch - list of launchable unit types, each: +-- unit - unit def name that carries/fires this missile. +-- cmd - "ATTACK" or "MANUALFIRE". +-- weaponId - weapon index on that unit. +-- stockpile - "silo" (sits on a silo pad) or "engine" (GetUnitStockpile); omit for Zenith. +-- scope - "combine": active only when Eos and Scylla are combined. + +local CMD_BASE = 39610 + +local missiles = { + { key = "zenith", unit = "zenith", label = "Zenith", col = 1, row = 1, + zenith = true, cmdType = "ICON_UNIT_OR_MAP", + tooltip = "Zenith (Meteor Controller)\nRains meteors on the target for a few seconds.", + launch = { { unit = "zenith", cmd = "ATTACK", weaponId = 1 } }, + }, + { key = "trinity", unit = "staticnuke", label = "Trinity", col = 2, row = 1, + tooltip = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile.", + launch = { { unit = "staticnuke", cmd = "ATTACK", weaponId = 1, stockpile = "engine" } }, + }, + { key = "reef", unit = "shipcarrier", label = "Reef Missile", col = 3, row = 1, + cmdType = "ICON_UNIT_OR_MAP", + tooltip = "Launch Disarm Missile\nDisables units temporarily.", + launch = { { unit = "shipcarrier", cmd = "MANUALFIRE", weaponId = 2, stockpile = "engine" } }, + }, + { key = "scylla", unit = "subtacmissile", label = "Scylla", col = 4, row = 1, + controllerScope = "separate", + tooltip = "Launch Scylla (Tactical Nuke)\nSubmarine-launched tactical nuke.", + launch = { { unit = "subtacmissile", cmd = "ATTACK", weaponId = 1, stockpile = "engine" } }, + }, + { key = "eos", unit = "tacnuke", label = "Eos", col = 1, row = 2, + siloBuild = "tacnuke", + tooltip = "Launch Eos (Tactical Nuke)\nTactical nuclear missile with high damage.\nAlt-click the map to build one.", + launch = { + { unit = "tacnuke", cmd = "ATTACK", weaponId = 1, stockpile = "silo" }, + -- Scylla folds in here only when the Combine Eos and Scylla option is on. + { unit = "subtacmissile", cmd = "ATTACK", weaponId = 1, stockpile = "engine", scope = "combine" }, + }, + }, + { key = "seismic", unit = "seismic", label = "Seismic", col = 2, row = 2, + siloBuild = "seismic", + tooltip = "Launch Seismic\nArea denial seismic missile, slows units.\nAlt-click the map to build one.", + launch = { { unit = "seismic", cmd = "ATTACK", weaponId = 1, stockpile = "silo" } }, + }, + { key = "shockley", unit = "empmissile", label = "Shockley", col = 3, row = 2, + siloBuild = "empmissile", + tooltip = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units.\nAlt-click the map to build one.", + launch = { { unit = "empmissile", cmd = "ATTACK", weaponId = 1, stockpile = "silo" } }, + }, + { key = "inferno", unit = "napalmmissile", label = "Inferno", col = 4, row = 2, + siloBuild = "napalmmissile", + tooltip = "Launch Inferno (Napalm)\nNapalm missile with persistent damage.\nAlt-click the map to build one.", + launch = { { unit = "napalmmissile", cmd = "ATTACK", weaponId = 1, stockpile = "silo" } }, + }, + { key = "zeno", unit = "missileslow", label = "Zeno", col = 5, row = 2, + siloBuild = "missileslow", + tooltip = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage.\nAlt-click the map to build one.", + launch = { { unit = "missileslow", cmd = "ATTACK", weaponId = 1, stockpile = "silo" } }, + }, +} + +-- Command ids come from the shared registry (LuaRules/Configs/customcmds.lua) so they +-- are reserved ZK commands, keyed MISSILE_. Fall back to the sequential range if the +-- registry is not available (both resolve to the same 39610.. block). +local registeredCmds = Spring.Utilities and Spring.Utilities.CMD +for i = 1, #missiles do + local m = missiles[i] + m.cmd = (registeredCmds and registeredCmds["MISSILE_" .. string.upper(m.key)]) or (CMD_BASE + i - 1) +end + +return missiles diff --git a/LuaUI/Widgets/gui_attack_aoe.lua b/LuaUI/Widgets/gui_attack_aoe.lua index b27df4f10e..dcb6a33ced 100644 --- a/LuaUI/Widgets/gui_attack_aoe.lua +++ b/LuaUI/Widgets/gui_attack_aoe.lua @@ -215,6 +215,25 @@ end --initialization -------------------------------------------------------------------------------- +-- Vlaunch (starburst) flight parameters, derived purely from the weapon def. +-- Used by the AoE preview here and exposed via WG for the missile launch preview, +-- so both compute impact points from an identical trajectory model. +local function BuildVlaunch(weaponDef) + if (weaponDef.uptime or 0) <= 0 then + return nil + end + -- In the first frame the projectile moves startVelocity + 2*Acceleration + local startSpeed = math.min(weaponDef.startvelocity + weaponDef.weaponAcceleration, weaponDef.projectilespeed) + return { + upFrames = math.floor(weaponDef.uptime * 30 + 0.5) - 2, + accel = weaponDef.weaponAcceleration, + turnRate = weaponDef.turnRate, + startSpeed = startSpeed, + startHeight = startHeights[weaponDef.name] or 0, + endSpeed = weaponDef.projectilespeed, + } +end + local function getWeaponInfo(weaponDef, unitDef) local retData @@ -277,18 +296,7 @@ local function getWeaponInfo(weaponDef, unitDef) else retData.aoe = 0 end - if (weaponDef.uptime or 0) > 0 then - -- In the first frame the projectile moves startVelocity + 2*Acceleration - local startSpeed = math.min(weaponDef.startvelocity + weaponDef.weaponAcceleration, weaponDef.projectilespeed) - retData.vlaunch = { - upFrames = math.floor(weaponDef.uptime * 30 + 0.5) - 2, - accel = weaponDef.weaponAcceleration, - turnRate = weaponDef.turnRate, - startSpeed = startSpeed, - startHeight = startHeights[weaponDef.name] or 0, - endSpeed = weaponDef.projectilespeed, - } - end + retData.vlaunch = BuildVlaunch(weaponDef) retData.cost = cost retData.mobile = not unitDef.isImmobile retData.waterWeapon = waterWeapon @@ -487,6 +495,39 @@ local function DrawAoE(tx, ty, tz, aoe, ee, alphaMult, offset, circleMode) glLineWidth(1) end +-- Shared blast-radius preview, exposed via WG so other widgets (e.g. the missile +-- launch UI) draw their AoE footprint with THIS falloff code instead of duplicating +-- it. Nested rings at (tx,ty,tz) whose alpha decays toward the edge by +-- edgeEffectiveness `ee`. `color` (optional {r,g,b[,a]}) overrides the ring colour; +-- `alphaMult` (optional) scales overall opacity (e.g. a pulse). Line width is derived +-- from the live camera distance, so callers need no per-frame setup of their own. +local function DrawAoEPreview(tx, ty, tz, aoe, ee, color, alphaMult) + if not aoe or aoe <= 0 then + return + end + ee = ee or 1 + local cx, cy, cz = GetCameraPosition() + local dx, dy, dz = cx - tx, cy - ty, cz - tz + local camDist = sqrt(dx*dx + dy*dy + dz*dz) + if camDist < 1 then + camDist = 1 + end + local r = (color and color[1]) or aoeColor[1] + local g = (color and color[2]) or aoeColor[2] + local b = (color and color[3]) or aoeColor[3] + local baseAlpha = ((color and color[4]) or aoeColor[4]) * (alphaMult or 1) + + glLineWidth(math.max(0.05, aoeLineWidthMult * aoe / camDist)) + for i = 1, numAoECircles do + local proportion = i / (numAoECircles + 1) + local alpha = baseAlpha * (1 - proportion) / (1 - proportion * ee) + glColor(r, g, b, alpha) + DrawCircle(tx, ty, tz, aoe * proportion) + end + glColor(1, 1, 1, 1) + glLineWidth(1) +end + -------------------------------------------------------------------------------- --dgun/noexplode -------------------------------------------------------------------------------- @@ -939,6 +980,33 @@ local function CalculateVlaunchImpact(info, fx, fy, fz, tx, ty, tz) return false end +-------------------------------------------------------------------------------- +-- Shared vlaunch impact query (used by the missile launch preview widget) +-------------------------------------------------------------------------------- + +local vlaunchInfoCache = {} + +-- Returns the terrain impact point (x, y, z) of a vlaunch (starburst) shot fired +-- from (fx, fy, fz) at (tx, ty, tz), or nil if it reaches the target +-- unobstructed or the weapon is not a vlaunch weapon. +local function GetVlaunchImpact(weaponDefID, fx, fy, fz, tx, ty, tz) + local info = vlaunchInfoCache[weaponDefID] + if info == nil then + local wd = WeaponDefs[weaponDefID] + local vlaunch = wd and BuildVlaunch(wd) + info = (vlaunch and {vlaunch = vlaunch, range = wd.range}) or false + vlaunchInfoCache[weaponDefID] = info + end + if not info then + return nil + end + local hx, hy, hz = CalculateVlaunchImpact(info, fx, fy, fz, tx, ty, tz) + if hx then + return hx, hy, hz + end + return nil +end + -------------------------------------------------------------------------------- --Main draw -------------------------------------------------------------------------------- @@ -1027,10 +1095,12 @@ function widget:Initialize() aoeDefInfo[unitDefID], dgunInfo[unitDefID], extraDrawRangeDefInfo[unitDefID] = SetupUnit(unitDef) end SetupDisplayLists() + WG.AttackAoE = {GetVlaunchImpact = GetVlaunchImpact, DrawAoEPreview = DrawAoEPreview} end function widget:Shutdown() DeleteDisplayLists() + WG.AttackAoE = nil end function widget:DrawWorld() diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 3a3823a112..802d9ae436 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -79,6 +79,10 @@ local buttonList -- is destroyed fully one frame later. local oldButtonList +-- Missile silos are factories, so they show up as factory buttons; the +-- hideMissileSilos option filters them out (the Launch button covers them). +local MISSILE_SILO_DEFID = UnitDefNames.staticmissilesilo and UnitDefNames.staticmissilesilo.id + local factoryList = {} local commanderList = {} local idleCons = {} -- [unitID] = true @@ -98,6 +102,14 @@ local myTeamID = Spring.GetMyTeamID() local buttonSizeShort = 4 local buttonCountLimit = 7 +-- Extra long-axis size (beyond one normal button) that the launch button needs +-- for its grid rows. The launch button is the last button, so its growth only +-- has to enlarge the background panel; while it exists it resizes itself and the +-- panel inline (see UpdateButton). wantLaunchRelayout defers the shrink for when +-- the button is removed (no missiles left) and can no longer relayout itself. +local launchButtonExtraLong = 0 +local wantLaunchRelayout = false + ------------------------------------------------------------------------------- ------------------------------------------------------------------------------- @@ -113,8 +125,10 @@ local IDLE_CONS_HIGHLIGHT_TIME = 0.5 local CONSTRUCTOR_ORDER = 1 local COMMANDER_ORDER = 2 local FACTORY_ORDER = 3 +local LAUNCH_ORDER = 4 local CONSTRUCTOR_BUTTON_ID = "cons" +local LAUNCH_BUTTON_ID = "launch" local exceptionList = { staticrearm = true, @@ -205,7 +219,7 @@ local defaultFacHotkeys = { } options_path = 'Settings/HUD Panels/Quick Selection Bar' -options_order = { 'showCoreSelector', 'vertical', 'buttonSizeLong', 'background_opacity', 'allowclickthrough', 'highlightidleconsinc', 'highlightidleconsincopacity', 'monitoridlecomms','monitoridlenano', 'monitorInbuiltCons', 'leftMouseCenter', 'lblSelectionIdle', 'selectprecbomber', 'selectidlecon', 'selectidlecon_all', 'lblSelection', 'selectcomm', 'horPaddingLeft', 'horPaddingRight', 'vertPadding', 'buttonSpacing', 'minButtonSpaces', 'specSpaceOverride', 'fancySkinning', 'leftsideofscreen'} +options_order = { 'showCoreSelector', 'vertical', 'buttonSizeLong', 'background_opacity', 'allowclickthrough', 'highlightidleconsinc', 'highlightidleconsincopacity', 'monitoridlecomms','monitoridlenano', 'monitorInbuiltCons', 'hideMissileSilos', 'showLaunchButton', 'leftMouseCenter', 'lblSelectionIdle', 'selectprecbomber', 'selectidlecon', 'selectidlecon_all', 'lblSelection', 'selectcomm', 'horPaddingLeft', 'horPaddingRight', 'vertPadding', 'buttonSpacing', 'minButtonSpaces', 'specSpaceOverride', 'fancySkinning', 'leftsideofscreen'} options = { showCoreSelector = { name = 'Selection Bar Visibility', @@ -289,6 +303,20 @@ options = { value = false, noHotkey = true, }, + hideMissileSilos = { + name = 'Hide missile silo buttons', + desc = 'Hide the individual missile silo selection buttons. The Launch button already aggregates all silos, so the per-silo buttons are usually redundant.', + type = 'bool', + value = true, + noHotkey = true, + }, + showLaunchButton = { + name = 'Show missile launch button', + desc = 'Show the aggregated missile Launch button, which opens the launcher to fire and build missiles.', + type = 'bool', + value = true, + noHotkey = true, + }, leftMouseCenter = { name = 'Swap Camera Center Button', desc = 'When enabled left click a commander or factory to center the camera on it. When disabled right click centers.', @@ -609,6 +637,44 @@ for i = 1, 16 do } end +-- Missile launcher hotkey, registered like the factory-selection hotkeys above. +-- Opens the launcher (same as clicking the Launch button in the selection bar). +local LAUNCH_HOTKEY_ACTION = "epic_chili_core_selector_launch" +-- Orange highlight matching the integral menu's selected-command colour, so the launch +-- button reads as "active" while the launcher is open, like an armed command button. +local LAUNCH_SELECTED_COLOR = {0.98, 0.48, 0.26, 0.85} + +-- Toggle the launcher: if it is already open, close it; otherwise open it and arm the +-- default missile. Shared by the launch button click and the launch hotkey. +local function OpenLauncher() + if not (WG.IntegralMenu and WG.IntegralMenu.OpenTab) then + return + end + if WG.IntegralMenu.IsHiddenTabOpen and WG.IntegralMenu.IsHiddenTabOpen("missiles") then + if WG.DismissLauncher then + WG.DismissLauncher() + elseif WG.IntegralMenu.CloseHiddenTab then + WG.IntegralMenu.CloseHiddenTab() + end + return + end + WG.IntegralMenu.OpenTab("missiles") + -- Arm a launch by default so the player can immediately click a target; + -- SelectDefaultMissile only picks a type that has a missile ready to fire. + if WG.SelectDefaultMissile then + WG.SelectDefaultMissile() + end +end +options_order[#options_order + 1] = "launch" +options["launch"] = { + name = "Open missile launcher", + desc = "Opens the missile launcher, same as clicking the Launch button in the selection bar.", + type = 'button', + hotkey = {key = 'L', mod = 'alt+'}, + path = 'Hotkeys/Selection', + OnChange = OpenLauncher, +} + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Helper Functions @@ -700,6 +766,8 @@ local function GetBackground(parent) local buttons = math.min(buttonCountLimit, math.max(buttonCount, options.minButtonSpaces.value)) local size = buttons*options.buttonSizeLong.value + (buttons - 1)*options.buttonSpacing.value + -- Extra room for the launch button, which grows past one normal button. + size = size + launchButtonExtraLong if options.vertical.value then size = size + 2*options.vertPadding.value else @@ -798,7 +866,11 @@ end -------------------------------------------------------------------------------- -- Button Handling -local function GetNewButton(parent, onClick, category, index, backgroundColor, imageFile, imageFile2) +-- getLongSize (optional): returns the button's extent along the long axis. Only +-- affects this button's own size, not the offset of following buttons, so it is +-- only safe to grow the last button (the launch button). Defaults to the normal +-- button size. +local function GetNewButton(parent, onClick, category, index, backgroundColor, imageFile, imageFile2, getLongSize) local position = 1 local hotkeyLabel, buildProgress, repeatImage, healthBar, hotkeyText, bottomLabel @@ -838,7 +910,13 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i } local externalFunctions = {} - + + -- Exposes the button control and its image so specialised buttons (e.g. the + -- launch button) can parent extra content onto them. + function externalFunctions.GetButtonControl() + return button, image + end + -- Update attributes function externalFunctions.SetImage(newImageFile) image.file = newImageFile @@ -977,6 +1055,9 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i local vPad = (options.vertical.value and options.buttonSpacing.value) or 0 local index = position - 1 + -- Offset uses the normal button size (all preceding buttons are normal); + -- only this button's own extent may be larger, via getLongSize. + local longSize = (getLongSize and getLongSize()) or options.buttonSizeLong.value if options.vertical.value then button._relativeBounds.left = options.horPaddingLeft.value button._relativeBounds.right = options.horPaddingRight.value @@ -985,7 +1066,7 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i button._relativeBounds.bottom = index*(options.buttonSizeLong.value + options.buttonSpacing.value) + options.vertPadding.value button._relativeBounds.width = nil button._givenBounds.width = nil - button._relativeBounds.height = options.buttonSizeLong.value + button._relativeBounds.height = longSize button:UpdateClientArea() else button._relativeBounds.left = index*(options.buttonSizeLong.value + options.buttonSpacing.value) + options.horPaddingLeft.value @@ -994,7 +1075,7 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i button._relativeBounds.top = options.vertPadding.value button._givenBounds.top = options.vertPadding.value button._relativeBounds.bottom = options.vertPadding.value - button._relativeBounds.width = options.buttonSizeLong.value + button._relativeBounds.width = longSize button._relativeBounds.height = nil button._givenBounds.height = nil button:UpdateClientArea() @@ -1151,7 +1232,7 @@ local function GetFactoryButton(parent, unitID, unitDefID, categoryOrder) local udid, num = next(queue[i]) constructionCount = constructionCount + num end - + UpdateTooltip(constructionCount) return true end @@ -1410,7 +1491,246 @@ local function GetConstructorButton(parent) externalFunctions.UpdateButton(dt) externalFunctions.UpdateHotkey() - + + return externalFunctions +end + +------------------------------------------------------------------------------- +------------------------------------------------------------------------------- +-- Launch button +-- +-- A single aggregate button that shows every stockpiled or building missile +-- type in a 2-column grid. Each cell arms that missile's launch command via the +-- missile widget. Added while missiles exist and self-removes (UpdateButton +-- returns false) once there are none. +-- +-- The button holds a minimum 2x2 grid (one normal button size) and grows along +-- its long axis past that. A single missile fills the whole button; with two or +-- more, cells keep a fixed half-button size and are not stretched to fill. + +local LAUNCH_COLUMNS = 2 +local LAUNCH_ROWS_MIN = 2 +-- Count label font size for a standard (2-column) cell; it scales up with the +-- cell when there are fewer columns (a single missile fills the whole button). +local LAUNCH_LABEL_FONT = 12 + +local function GetLaunchButton(parent) + -- Clicking anywhere on the button opens the launcher (shared with the launch hotkey). + local function OnClick(mouse) + OpenLauncher() + end + + local function GetLongSize() + return options.buttonSizeLong.value + launchButtonExtraLong + end + + local button = GetNewButton(parent, OnClick, LAUNCH_ORDER, 0, BUTTON_COLOR, nil, nil, GetLongSize) + local buttonControl = button.GetButtonControl() + local defaultFocusColor = buttonControl.focusColor -- restore this when not highlighted + button.SetImageVisible(false) -- the missile grid replaces the single icon + + local cells = {} + local lastLayoutKey = false + local lastHotkey = false -- last hotkey string pushed to the label/tooltip + local lastLauncherActive = nil -- last launch-active state pushed to the focus colour + + -- Cells are display only (icon + count + build progress); they are parented + -- directly to the button and do not handle clicks, so the whole button stays + -- clickable and opens the launcher menu. + local function GetCell(i) + if cells[i] then + return cells[i] + end + local cell = {} + cell.image = Image:New { + parent = buttonControl, + keepAspect = false, + file = "", + } + cell.bar = Progressbar:New { + parent = cell.image, + x = "5%", y = "5%", right = "5%", bottom = "5%", + value = 0, max = 1, caption = false, noFont = true, + color = {0.7, 0.7, 0.4, 0.6}, + backgroundColor = {1, 1, 1, 0.01}, + } + -- Parented to the image (not the button) so the count always draws on top of + -- the icon and its progress bar. Position and shadow match the standard bottom + -- count label (SetBottomLabel, used by the idle-con/factory buttons) so a single + -- missile's count reads identically. + cell.label = Label:New { + parent = cell.image, + x = 0, y = 0, right = 5, bottom = 5, + autosize = false, + align = "right", valign = "bottom", + objectOverrideFont = WG.GetFont(LAUNCH_LABEL_FONT), + fontShadow = true, + caption = "", + } + cells[i] = cell + return cell + end + + local externalFunctions = { + SetPosition = button.SetPosition, + MoveUp = button.MoveUp, + MoveDown = button.MoveDown, + GetOrder = button.GetOrder, + UpdatePosition = button.UpdatePosition, + } + + -- Keep the single base icon hidden; the grid is drawn from cell controls. + function externalFunctions.SetImageVisible() + end + + function externalFunctions.UpdateButton(dt) + local icons = WG.missileActiveIcons or {} + local n = #icons + if n == 0 then + -- No missiles: reset any growth and report inactive so the list handler + -- removes the button. + if launchButtonExtraLong ~= 0 then + launchButtonExtraLong = 0 + wantLaunchRelayout = true + end + return false + end + + -- A single missile fills the whole button (a 1x1 grid over the full 2x2 + -- space); two or more use the 2-column grid with a 2x2 minimum. + local singleCell = (n == 1) + + -- Show the hotkey in the corner only when a single cell leaves room for it + -- (one missile, or the silo placeholder); with a full grid it would cover a + -- cell, so it lives in the tooltip instead. The tooltip always carries it. + local hk = (WG.crude and WG.crude.GetHotkey(LAUNCH_HOTKEY_ACTION)) or '' + if hk ~= lastHotkey then + lastHotkey = hk + button.SetTooltip("Missile launcher" .. ((hk ~= '') and (" (" .. hk .. ")") or "")) + end + button.SetHotkey(singleCell and hk or '') + local cols = singleCell and 1 or LAUNCH_COLUMNS + local rows = math.max(1, math.ceil(n / cols)) + + -- A normal button holds two rows (two columns); each further row adds half + -- a button length. Grow the button, and the panel, past two rows. + local desiredLong = math.max(options.buttonSizeLong.value, rows * options.buttonSizeLong.value / 2) + local extra = desiredLong - options.buttonSizeLong.value + if extra ~= launchButtonExtraLong then + launchButtonExtraLong = extra + -- Resize the button and background panel right here, so the taller + -- button and the cell grid below update together in the same pass + -- instead of a frame apart (the button is the last one, so growing it + -- does not shift any other button's offset). + button.UpdatePosition() + if mainBackground then + mainBackground.UpdateSize() + end + end + + -- The long axis (height when vertical, width when horizontal) was just + -- changed via UpdatePosition, but the control's realized width/height only + -- catches up on a later Chili pass. Use the computed size for that axis so + -- the cells land at their final positions in the same frame the button + -- grows, with no lag; the short axis is fixed by padding and safe to read. + local width, height + if options.vertical.value then + width = buttonControl.width or 0 + height = desiredLong + else + width = desiredLong + height = buttonControl.height or 0 + end + + -- With two or more missiles, divide by the minimum grid (2x2) so a single + -- row keeps its half-button height instead of stretching. A single missile + -- (cols == 1) fills the whole button. + local cw = width / cols + local ch = height / (singleCell and 1 or math.max(LAUNCH_ROWS_MIN, rows)) + + -- Reposition cells only when the missile set or grid size changes, not on + -- every count/progress tick. Re-running SetPos each frame re-lays out the + -- cell labels and makes the count jitter vertically while a missile builds. + local iconParts = {} + for i = 1, n do + iconParts[i] = icons[i].icon + end + local layoutKey = table.concat(iconParts, ",") .. "|" .. cols .. "|" .. width .. "x" .. height + if layoutKey ~= lastLayoutKey then + lastLayoutKey = layoutKey + -- Count font: a single cell fills the whole button, so use the standard + -- idle-con / factory count size (14) rather than scaling up. Multiple cells + -- keep the per-cell scaled size (a wider cell gets a bigger number). + local baseCellWidth = width / LAUNCH_COLUMNS + local labelFont = WG.GetFont(singleCell and 14 + or math.max(1, math.floor(LAUNCH_LABEL_FONT * cw / baseCellWidth + 0.5))) + for i = 1, n do + local cell = GetCell(i) + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local cx, cy = col * cw, row * ch + if icons[i].isSilo then + -- Inset the silo placeholder to match the factory buttons' icon + + -- construction border (5% inset), so the border is not oversized. + local ix, iy = cw * 0.05, ch * 0.05 + cell.image:SetPos(cx + ix, cy + iy, cw - 2 * ix, ch - 2 * iy) + else + cell.image:SetPos(cx, cy, cw, ch) + end + cell.image:SetVisibility(true) + cell.image.file = icons[i].icon + -- Silo placeholder (no missiles yet) gets the factory construction + -- border to read as "build here"; missiles show without it. + cell.image.file2 = icons[i].isSilo and FACTORY_FRAME or nil + cell.image:Invalidate() + cell.label.font = labelFont + cell.label.objectOverrideFont = labelFont + cell.label:Invalidate() + cell.label:SetVisibility(true) + end + for i = n + 1, #cells do + cells[i].image:SetVisibility(false) + cells[i].label:SetVisibility(false) + end + end + + -- Update the dynamic per-cell values every frame; these change the progress + -- bar fill and the count text in place without moving any cell. + for i = 1, n do + local cell = cells[i] + local data = icons[i] + if (data.progress or 0) > 0 then + cell.bar:SetValue(data.progress) + cell.bar:SetVisibility(true) + else + cell.bar:SetVisibility(false) + end + cell.label:SetCaption((data.count > 0) and tostring(data.count) or "") + end + + -- Highlight the button (background + focus/hover colour) while launch mode is + -- active -- launcher tab open, or a launch command still armed after firing -- + -- matching the integral menu's selected-command colour. Applied last (after any + -- resize/relayout above, which reset the colour) and every update, so the + -- highlight is not lost when the missile count changes. + local launcherActive = (WG.IsLaunchActive and WG.IsLaunchActive()) or false + if launcherActive ~= lastLauncherActive then + lastLauncherActive = launcherActive + buttonControl.focusColor = launcherActive and LAUNCH_SELECTED_COLOR or defaultFocusColor + end + button.SetBackgroundColor(launcherActive and LAUNCH_SELECTED_COLOR or BUTTON_COLOR) + return true + end + + function externalFunctions.UpdateHotkey() + end + + function externalFunctions.Destroy() + button.Destroy() + button = nil + end + + externalFunctions.UpdateButton(0) return externalFunctions end @@ -1747,10 +2067,27 @@ local function ClearData() buttonList = GetButtonListHandler(mainBackground) buttonList.AddButton(CONSTRUCTOR_BUTTON_ID, GetConstructorButton(buttonHolder)) InitializeUnits() - + buttonList.SetImagesVisible(false) end +-- Rebuild the button list when the silo-hiding option changes so existing silo +-- buttons appear/disappear immediately (ClearData re-scans owned units; the old +-- list is disposed next Update, same as on a team change). +options.hideMissileSilos.OnChange = function() + if buttonList then + ClearData() + end +end + +-- Toggling the launch button off should remove an existing one at once (it otherwise +-- only self-removes when the last missile is gone); rebuild to apply immediately. +options.showLaunchButton.OnChange = function() + if buttonList then + ClearData() + end +end + ------------------------------------------------------------------------------- ------------------------------------------------------------------------------- -- Callins @@ -1762,7 +2099,9 @@ function widget:UnitCreated(unitID, unitDefID, unitTeam) local ud = UnitDefs[unitDefID] if ud.isFactory and (not exceptionArray[unitDefID]) then - AddFac(unitID, unitDefID) + if not (options.hideMissileSilos.value and unitDefID == MISSILE_SILO_DEFID) then + AddFac(unitID, unitDefID) + end elseif ud.customParams.level then AddComm(unitID, unitDefID) elseif options.monitorInbuiltCons.value and CanBeAnIdleCons(ud) then @@ -1951,12 +2290,32 @@ function widget:Update(dt) --debugIdleConsState() end + -- Add the launch button as soon as there are missiles to launch, every frame + -- rather than only in the throttled block below, so it appears promptly when + -- a missile starts building. Populate it at once so it is not shown empty. + if options.showLaunchButton.value and WG.missileActiveIcons and #WG.missileActiveIcons > 0 and not buttonList.GetButton(LAUNCH_BUTTON_ID) then + local launchButton = GetLaunchButton(buttonHolder) + buttonList.AddButton(LAUNCH_BUTTON_ID, launchButton) + launchButton.UpdateButton(dt) + end + timer = timer + dt if timer < UPDATE_FREQUENCY then return end - + buttonList.UpdateButtons(timer) + + -- The launch button was removed (no missiles left): shrink the panel and + -- relayout. Growth while the button exists is handled inline in UpdateButton. + if wantLaunchRelayout then + wantLaunchRelayout = false + if mainBackground then + mainBackground.UpdateSize() + end + buttonList.UpdateLayout() + end + timer = 0 end diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index 8265780711..e3ed861ce6 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -125,13 +125,26 @@ end local commandPanels, commandPanelMap, commandDisplayConfig, hiddenCommands, textConfig, buttonLayoutConfig, instantCommands, cmdPosDef = include("Configs/integral_menu_config.lua") +-- Commands whose displayConfig requests it draw their command.name (count / progress string) like stockpile. +for cmdID, displayConfig in pairs(commandDisplayConfig) do + if displayConfig.drawName then + DRAW_NAME_COMMANDS[cmdID] = true + end +end + local statePanel = {} local tabPanel local selectionIndex = 0 +local lastSelectionSignature = false -- to detect selection changes for tab defaulting local background local returnToOrdersCommand = false local simpleModeEnabled = true +-- Name of a hiddenTab panel (e.g. the missiles Launch tab) to reveal this cycle, +-- or false when no hidden tab is open. Hidden tabs are kept out of the tab strip +-- until something calls WG.IntegralMenu.OpenTab; selecting any other tab clears it. +local revealHiddenTab = false + local buildTabHolder, buttonsHolder -- Required for padding update setting -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -650,7 +663,9 @@ end local function UpdateReturnToOrders(cmdID) if returnToOrdersCommand and returnToOrdersCommand ~= cmdID then - commandPanelMap.orders.tabButton.DoClick() + if commandPanelMap.orders.tabButton.IsTabPresent() then + commandPanelMap.orders.tabButton.DoClick() + end returnToOrdersCommand = false end @@ -2089,6 +2104,11 @@ local function GetTabPanel(parent, rows, columns) if not tabList then return end + -- Selecting any other tab closes an open hidden tab (the missiles Launch + -- tab), so clicking a tab dismisses it as expected. + if revealHiddenTab and name ~= revealHiddenTab then + revealHiddenTab = false + end currentTab = name for i = 1, #tabList do local data = tabList[i] @@ -2101,6 +2121,17 @@ local function GetTabPanel(parent, rows, columns) function externalFunctions.SetTabs(newTabList, showTabs, variableHide, tabToSelect) if TabListsAreIdentical(newTabList, tabList) then + -- Same tabs, but the requested selection may differ -- e.g. reopening the + -- hidden Launch tab after visiting Orders leaves the tab list unchanged. + -- Re-select the target so its panel actually shows. + if tabToSelect and currentTab ~= tabToSelect then + for i = 1, #tabList do + if tabList[i].name == tabToSelect then + tabList[i].DoClick() + break + end + end + end return end if currentSelectedIndex and tabList[currentSelectedIndex] then @@ -2109,7 +2140,10 @@ local function GetTabPanel(parent, rows, columns) tabList = newTabList tabHolder:ClearChildren() for i = 1, #tabList do - if showTabs then + -- A hiddenTab (the missiles Launch tab) is never placed in the tab strip, + -- even while it is the selected tab: its content shows but no tab button + -- appears. It still gets DoClick below so its panel is displayed. + if showTabs and not tabList[i].hiddenTab then tabHolder:AddChild(tabList[i].button) tabList[i].SetHideHotkey(variableHide) tabList[i].SetHotkeyActive(hotkeysActive) @@ -2258,6 +2292,19 @@ local function ProcessAllCommands(commands, customCommands) local factoryUnitID, factoryUnitDefID, fakeFactory, selectedUnitCount = GetSelectionValues() local unitMobilePanelSize = GetUnitMobilePanelSize(commands, factoryUnitDefID) + -- Detect an actual selection change (vs a command-only refresh) so that + -- selecting a unit while on a global tab (missiles) switches to its default. + local selectionSignature = table.concat(spGetSelectedUnits(), ",") + local selectionChanged = (selectionSignature ~= lastSelectionSignature) + lastSelectionSignature = selectionSignature + + -- A selection change dismisses an opened hidden tab (the launch menu), so + -- selecting a unit returns to its normal command tabs rather than staying on + -- the revealed missiles tab. + if selectionChanged and revealHiddenTab then + revealHiddenTab = false + end + selectionIndex = selectionIndex + 1 for i = 1, #commandPanels do @@ -2319,10 +2366,17 @@ local function ProcessAllCommands(commands, customCommands) end -- Determine which tabs to display and which to select + local forceShowTabs = false for i = 1, #commandPanels do local data = commandPanels[i] - if data.commandCount ~= 0 then + -- A hiddenTab (the missiles Launch tab) stays out of the tab strip until it + -- is explicitly opened via WG.IntegralMenu.OpenTab, which sets revealHiddenTab. + local hidden = data.hiddenTab and (data.name ~= revealHiddenTab) + if data.commandCount ~= 0 and not hidden then tabsToShow[#tabsToShow + 1] = data.tabButton + if data.alwaysShowTab then + forceShowTabs = true + end data.buttons.ClearOldButtons(selectionIndex) if data.queue then data.queue.ClearOldButtons(selectionIndex) @@ -2332,6 +2386,25 @@ local function ProcessAllCommands(commands, customCommands) end end end + + -- A freshly opened hidden tab takes selection priority and forces the strip + -- visible. If it is no longer available (e.g. all missile units were lost), + -- clear the open state so it does not get stuck. + if revealHiddenTab then + local revealPresent = false + for i = 1, #tabsToShow do + if tabsToShow[i].name == revealHiddenTab then + revealPresent = true + break + end + end + if revealPresent then + tabToSelect = revealHiddenTab + forceShowTabs = true + else + revealHiddenTab = false + end + end statePanel.holder:SetVisibility(statePanel.commandCount ~= 0) if statePanel.commandCount ~= 0 then @@ -2346,12 +2419,33 @@ local function ProcessAllCommands(commands, customCommands) tabPanel.ClearTabs() lastTabSelected = false else - tabPanel.SetTabs(tabsToShow, #tabsToShow > 1, not factoryUnitDefID, tabToSelect) + -- Fall back to the first shown tab if the intended one is not present + -- (e.g. only the missiles tab is available while nothing is selected, + -- so the default "orders" tab does not exist to be selected). + local tabToSelectPresent = false + for i = 1, #tabsToShow do + if tabsToShow[i].name == tabToSelect then + tabToSelectPresent = true + break + end + end + if not tabToSelectPresent then + tabToSelect = tabsToShow[1].name + end + tabPanel.SetTabs(tabsToShow, (#tabsToShow > 1) or forceShowTabs, not factoryUnitDefID, tabToSelect) lastTabSelected = tabToSelect end -- Keeps main window for tweak mode.SetIntegralVisibility(visible) SetIntegralVisibility(not (#tabsToShow == 0 and selectedUnitCount == 0)) + + -- The buttons were just rebuilt as unselected. UpdateButtonSelection only reacts to + -- the active command *changing*, so with the same command still armed (e.g. a sticky + -- missile launch after firing) it would leave the rebuilt button unselected. Force + -- the active command's button to re-highlight here. + lastCmdID = nil + local _, activeCmdID = spGetActiveCommand() + UpdateButtonSelection(activeCmdID) end -------------------------------------------------------------------------------- @@ -2428,7 +2522,9 @@ local function InitializeControls() local function ReturnToOrders(cmdID) if options.selectionClosesTabOnSelect.value then - if commandPanelMap.orders then + -- Only return to orders if it is actually present; otherwise (e.g. + -- missiles tab with nothing selected) stay on the current tab. + if commandPanelMap.orders and commandPanelMap.orders.tabButton.IsTabPresent() then commandPanelMap.orders.tabButton.DoClick() end elseif options.selectionClosesTab.value and cmdID then @@ -2448,6 +2544,9 @@ local function InitializeControls() } commandHolder:SetVisibility(false) + -- Only tabs with their own optionName get a hotkey label. Tabs without one + -- (missiles, orders, units_factory) previously borrowed the Units hotkey and + -- displayed "(N)", but N is the hold-fire key and never switches to them. local hotkey if data.optionName then hotkey = GetActionHotkey(EPIC_NAME .. data.optionName) @@ -2484,7 +2583,8 @@ local function InitializeControls() end data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect) - + data.tabButton.hiddenTab = data.hiddenTab + if data.gridHotkeys and ((not data.disableableKeys) or options.unitsHotkeys2.value) then data.buttons.ApplyGridHotkeys(gridMap, (gridCustomOverrides and gridCustomOverrides[data.name]) or {}) end @@ -2647,6 +2747,17 @@ options.fancySkinning.OnChange = UpdateBackgroundSkin local externalFunctions = {} -- Appear unused in repo but are used by missions. local initialized = false +-- Lets other widgets show a factory-style build progress bar on a command button +-- (e.g. the missile command center showing stockpile build progress). +function externalFunctions.SetCommandProgress(cmdID, progress) + local button = buttonsByCommand[cmdID] + if button then + button.SetProgressBar(progress or 0) + return true + end + return false +end + function externalFunctions.GetCommandButtonPosition(cmdID) if not buttonsByCommand[cmdID] then return @@ -2680,6 +2791,32 @@ function externalFunctions.UpdateCommands() ProcessAllCommands(commands, customCommands) end +-- Reveal a hiddenTab panel (e.g. the missiles "Launch" tab) and select it. The +-- tab is otherwise kept out of the tab strip; opening it forces the strip +-- visible. Selecting any other tab closes it again (see SwitchToTab). +function externalFunctions.OpenTab(tabName) + if not initialized then + return + end + revealHiddenTab = tabName + externalFunctions.UpdateCommands() +end + +function externalFunctions.CloseHiddenTab() + if not revealHiddenTab then + return + end + revealHiddenTab = false + externalFunctions.UpdateCommands() +end + +function externalFunctions.IsHiddenTabOpen(tabName) + if tabName then + return revealHiddenTab == tabName + end + return revealHiddenTab ~= false +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Widget Interface diff --git a/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua b/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua index 90f5d1fad5..51eb2865b4 100644 --- a/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua +++ b/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua @@ -2150,7 +2150,7 @@ local function GetSingleUnitInfoPanel(parentControl, isTooltipVersion) local healthPos if shieldBarUpdate then if ud and ((ud.shieldPower or 0) > 0 or ud.level) then - local shieldPower = (spGetUnitRulesParam(unitID, "comm_shield_max") or ud.shieldPower) * (Spring.GetUnitRulesParam(unitID, "totalShieldMaxMult") or 1) + local shieldPower = (spGetUnitRulesParam(unitID, "comm_shield_max") or ud.shieldPower or 0) * (Spring.GetUnitRulesParam(unitID, "totalShieldMaxMult") or 1) local _, shieldCurrentPower = spGetUnitShieldState(unitID, -1) if shieldCurrentPower and shieldPower then shieldBarUpdate(true, nil, shieldCurrentPower, shieldPower, (shieldCurrentPower < shieldPower) and GetUnitShieldRegenString(unitID, ud)) diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua new file mode 100644 index 0000000000..33301e8464 --- /dev/null +++ b/LuaUI/Widgets/missile_command_center.lua @@ -0,0 +1,1138 @@ +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:GetInfo() + return { + name = "Missile Command Center", + desc = "Adds missile launch commands and previews where each shot will land, marking terrain that blocks it.", + author = "Amnykon", + date = "2021-07-30", + license = "GNU GPL, v2 or later", + layer = 0, + handler = true, + enabled = true, + } +end + +function widget:Initialize() + WG.missileActiveIcons = {} +end + +options_path = 'Settings/HUD Panels/Missile Launcher' +options_order = {'combineEosScylla'} +options = { + combineEosScylla = { + name = 'Combine Eos and Scylla', + desc = 'Show Eos (silo tactical nuke) and Scylla (submarine tactical nuke) as a single Launch button instead of two.', + type = 'bool', + value = false, + noHotkey = true, + }, +} + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +local glVertex = gl.Vertex +local glPushAttrib = gl.PushAttrib +local glLineStipple = gl.LineStipple +local glDepthTest = gl.DepthTest +local glLineWidth = gl.LineWidth +local glColor = gl.Color +local glBeginEnd = gl.BeginEnd +local glPopAttrib = gl.PopAttrib +local glPopMatrix = gl.PopMatrix +local glPushMatrix = gl.PushMatrix +local glScale = gl.Scale +local glTranslate = gl.Translate +local glDrawGroundCircle = gl.DrawGroundCircle +local GL_LINE_LOOP = GL.LINE_LOOP + +local circleDivs = 64 + +local PI = math.pi +local cos = math.cos +local sin = math.sin + +local aoeColor = {1, 0, 0, 1} +local floor = math.floor + +local pulse_timmer = Spring.GetTimer() +local function getPulse() + local time = Spring.DiffTimers(Spring.GetTimer(), pulse_timmer) + return 1 - (time - floor(time)) +end + +local function UnitCircleVertices() + for i = 1, circleDivs do + local theta = 2 * PI * i / circleDivs + glVertex(cos(theta), 0, sin(theta)) + end +end + +local function DrawCircle(x, y, z, radius) + glPushMatrix() + glTranslate(x, y, z) + glScale(radius, radius, radius) + glBeginEnd(GL_LINE_LOOP, UnitCircleVertices) + glPopMatrix() +end + +-- Blast footprint at the impact point. Reuses the Attack AoE widget's falloff +-- renderer (via WG) so the launch preview and the stock force-fire preview draw an +-- identical ring stack; the pulse is passed through as an overall-alpha multiplier. +local function drawBlastRadius(tx, ty, tz, weaponDef) + if not (WG.AttackAoE and WG.AttackAoE.DrawAoEPreview) then return end + WG.AttackAoE.DrawAoEPreview(tx, ty, tz, weaponDef.damageAreaOfEffect, weaponDef.edgeEffectiveness, aoeColor, getPulse()) +end + +-- Faint ring at the intended target, drawn when the shot is blocked so it is +-- clear the impact ring has been relocated short of where the player aimed. +local function drawGhostTarget(tx, ty, tz, weaponDef) + glLineWidth(1) + glColor(1, 1, 1, 0.25) + DrawCircle(tx, ty, tz, weaponDef.damageAreaOfEffect) + glColor(1, 1, 1, 1) +end + +local function drawLine(x1, y1, z1, x2, y2, z2) + glPushAttrib(GL.LINE_BITS) + glLineStipple("springdefault") + glDepthTest(false) + glLineWidth(1) + glColor(1, 0, 0, 1) + glBeginEnd(GL.LINES, function() + glVertex(x1, y1, z1) + glVertex(x2, y2, z2) + end) + + glColor(1, 1, 1, 1) + glLineStipple(false) + glPopAttrib() +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +local function getMouseTargetPosition() + local mx, my = Spring.GetMouseState() + local mouseTargetType, mouseTarget = Spring.TraceScreenRay(mx, my, false, true, false, true) + + if (mouseTargetType == "ground") then + return mouseTarget[1], mouseTarget[2], mouseTarget[3], true + elseif (mouseTargetType == "unit") then + return Spring.GetUnitPosition(mouseTarget) + elseif (mouseTargetType == "feature") then + local _, coords = Spring.TraceScreenRay(mx, my, true, true, false, true) + if coords and coords[3] then + return coords[1], coords[2], coords[3], true + else + return Spring.GetFeaturePosition(mouseTarget) + end + else + return nil + end +end + +-- Squared XZ distance -- shared engine utility rather than a hand-rolled copy. +local DistSq = Spring.Utilities.Vector.DistSq + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +local function missile_class() + local self = {} + + self.cmdType = CMDTYPE.ICON_MAP + + function self:getOrderableUnits() + local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + local units = {} + + for _, unitID in ipairs(teamUnits) do + if self:canGiveOrder(unitID) then + units[#units + 1] = unitID + end + end + + return units + end + + function self:getNumberOfQueueLaunches(unit) + local unitDefID = Spring.GetUnitDefID(unit) + if not unitDefID then return 0 end + + local unitType = self.launchableTypes[unitDefID] + if not unitType then return 0 end + + local numStockpiled = unitType.getStockpile(unit) + if not numStockpiled or numStockpiled == 0 then return 0 end + + local cmdQueue = Spring.GetUnitCommands(unit, 100) + if not cmdQueue then return 0 end + + local numQueued = 0 + for _, cmd in ipairs(cmdQueue) do + if cmd and cmd.id == unitType.launchCmd then numQueued = numQueued + 1 end + end + + return numQueued + end + + function self:getCount() + local count = 0 + for _, unit in ipairs(self:getOrderableUnits()) do + if not Spring.GetUnitIsDead(unit) then + local unitDefID = Spring.GetUnitDefID(unit) + if unitDefID then + local type = self.launchableTypes[unitDefID] + if type then + local stockpile = type.getStockpile(unit) + if stockpile then + count = count + stockpile - self:getNumberOfQueueLaunches(unit) + end + end + end + end + end + return count + end + + function self:getMaxBuildProgress() + local maxProgress = 0 + local allUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + + for _, unitID in ipairs(allUnits) do + if not Spring.GetUnitIsDead(unitID) then + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID and self.launchableTypes[unitDefID] then + -- Silo-built missiles exist as nanoframes while under construction. + local _, _, _, _, buildProgress = Spring.GetUnitHealth(unitID) + if buildProgress and buildProgress < 1 then + maxProgress = math.max(maxProgress, buildProgress) + else + -- Stockpiling weapons (Trinity, Reef, subtac) report progress toward + -- the next missile via the "gadgetStockpile" rules param. Zero-K + -- reimplements stockpiling in a gadget, so the engine's + -- GetUnitStockpile build percent is pinned to 1 and unusable here. + local stockpileProgress = Spring.GetUnitRulesParam(unitID, "gadgetStockpile") + if stockpileProgress and stockpileProgress > 0 and stockpileProgress < 1 then + maxProgress = math.max(maxProgress, stockpileProgress) + end + end + end + end + end + return maxProgress + end + + function self:canGiveOrder(unit) + local _, _, _, _, build = Spring.GetUnitHealth(unit) + local type = self.launchableTypes[Spring.GetUnitDefID(unit)] + if not type then return false end + + local count = type.getStockpile(unit) + - self:getNumberOfQueueLaunches(unit) + + return build == 1 and count ~= 0 + end + + -- Whether this launcher's vlaunch arc is projected to slam into terrain (a hill in + -- the way) short of the aim point, using the same trajectory model the Attack AoE + -- widget draws. Non-vlaunch weapons report nil (never blocked), so this is a no-op + -- for them. Fire origin is computed like drawWorld/Attack AoE so the three agree. + function self:isShotBlocked(unit, params) + if not (WG.AttackAoE and WG.AttackAoE.GetVlaunchImpact) then return false end + + local unitDefID = Spring.GetUnitDefID(unit) + local type = self.launchableTypes[unitDefID] + if not type then return false end + + local unitDef = UnitDefs[unitDefID] + local weapon = unitDef and unitDef.weapons and unitDef.weapons[type.weaponId] + if not weapon then return false end + + local _, _, _, ux, uy, uz = Spring.GetUnitPosition(unit, true) + if not ux then return false end + if unitDef.isImmobile then + uy = uy + Spring.GetUnitRadius(unit) + end + + local ty = Spring.GetGroundHeight(params.x, params.z) or 0 + local hx = WG.AttackAoE.GetVlaunchImpact(weapon.weaponDef, ux, uy, uz, params.x, ty, params.z) + return hx ~= nil + end + + function self:preferredUnit(unit1, unit2, params) + local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) + if not unit2x then return unit1 end + + local type2 = self.launchableTypes[Spring.GetUnitDefID(unit2)] + if not type2 then return unit1 end + + local unit2Dist = DistSq(params.x, params.z, unit2x, unit2z) + local weaponDef2 = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit2)].weapons[type2.weaponId].weaponDef] + if not weaponDef2 then return unit1 end + + local range = weaponDef2.range + + if unit2Dist > range * range then return unit1 end + + if not unit1 then return unit2 end + + local type1 = self.launchableTypes[Spring.GetUnitDefID(unit1)] + if not type1 then return unit2 end + + local weaponDef1 = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit1)].weapons[type1.weaponId].weaponDef] + if not weaponDef1 then return unit2 end + + -- A shot that actually lands outranks everything else, including an explicit + -- selection: firing a silo whose arc slams into terrain short of the target is + -- always a bad experience (the preview even shows the missile coming from there). + -- If every candidate is blocked this tier is a wash and we fall through, so a + -- launch is still issued (never silently no-ops). + local blocked1 = self:isShotBlocked(unit1, params) + local blocked2 = self:isShotBlocked(unit2, params) + + if blocked1 and not blocked2 then + return unit2 + elseif blocked2 and not blocked1 then + return unit1 + end + + local unit1Silo = Spring.GetUnitRulesParam(unit1, "missile_parentSilo") + local unit1Selected = params.selectedUnits[unit1] or (unit1Silo and params.selectedUnits[unit1Silo]) + + local unit2Silo = Spring.GetUnitRulesParam(unit2, "missile_parentSilo") + local unit2Selected = params.selectedUnits[unit2] or (unit2Silo and params.selectedUnits[unit2Silo]) + + if unit1Selected and not unit2Selected then + return unit1 + elseif unit2Selected and not unit1Selected then + return unit2 + end + + local queueDelta = self:getNumberOfQueueLaunches(unit1) - self:getNumberOfQueueLaunches(unit2) + + if queueDelta > 0 then + return unit2 + elseif queueDelta < 0 then + return unit1 + end + + local _, reloaded1, _ = Spring.GetUnitWeaponState(unit1, type1.weaponId) + local _, reloaded2, _ = Spring.GetUnitWeaponState(unit2, type2.weaponId) + + if reloaded1 and not reloaded2 then + return unit1 + elseif not reloaded1 and reloaded2 then + return unit2 + end + + local unit1x, _, unit1z = Spring.GetUnitPosition(unit1) + local unit1Dist = DistSq(params.x, params.z, unit1x, unit1z) + + local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) + local unit2Dist = DistSq(params.x, params.z, unit2x, unit2z) + + if unit1Dist < unit2Dist then + return unit1 + end + + if unit2Dist < unit1Dist then + return unit2 + end + + return unit1 + end + + + function self:getPreferredUnit(params) + local units = self:getOrderableUnits() + + params.selectedUnits = {} + for _, unit in ipairs(Spring.GetSelectedUnits() or {}) do + params.selectedUnits[unit] = true + end + + local preferredUnit + + for _, unitID in ipairs(units) do + if self:canGiveOrder(unitID) then + preferredUnit = self:preferredUnit(preferredUnit, unitID, params) + end + end + + return preferredUnit + end + + function self:commandsChanged() + -- A hidden controller (Scylla while combined with Eos) is simply not registered, + -- so it has no button. Registering a descriptor with a "hidden" field instead + -- makes the engine log "GetLuaCmdDescList() bad entry". + if self.hidden then return end + + local customCommands = widgetHandler.customCommands + + -- All fields must be present and valid, or the engine logs + -- "GetLuaCmdDescList() bad entry" for the descriptor. name is also used by + -- the integral menu to draw the stockpile count. + customCommands[#customCommands+1] = { + id = self.cmd, + type = self.cmdType, + name = self.displayName or "", + cursor = 'Attack', + action = "missile_" .. self.name, + texture = "LuaUI/Images/commands/Bold/missile.png", + tooltip = "Launch missile.", + disabled = self.disabled or false, + params = {}, + } + end + + function self:commandNotify(cmdID, cmdParams, cmdOptions) + if cmdID == self.cmd then + local x,y,z + if #cmdParams == 1 then + x,y,z = Spring.GetUnitPosition(cmdParams[1]) + else + x,y,z = cmdParams[1], cmdParams[2], cmdParams[3] + end + local unit = self:getPreferredUnit{x = x, z = z} + if not unit then return true end + local unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] + if not unitType then return true end + + -- Insert after any launches already queued but before other orders (e.g. + -- moves), so multiple shift-clicks fire in click order and still launch + -- before the unit moves away. + local insertPos = 0 + local cmdQueue = Spring.GetUnitCommands(unit, 100) + if cmdQueue then + for i = 1, #cmdQueue do + if cmdQueue[i].id == unitType.launchCmd then + insertPos = i + else + break + end + end + end + Spring.GiveOrderToUnit(unit, CMD.INSERT, {insertPos, unitType.launchCmd, CMD.OPT_SHIFT, unpack(cmdParams)}, CMD.OPT_ALT) + return true + end + end + + function self:action(x, y, mouse) + if self:getCount() == 0 then return end + + local cmdIndex = Spring.GetCmdDescIndex(self.cmd) + if not cmdIndex then return end + + local left, right = true, false + local alt, ctrl, meta, shift = Spring.GetModKeyState() + Spring.SetActiveCommand(cmdIndex, 1, left, right, alt, ctrl, meta, shift) + end + + -- Called only when this command is the active one (see widget:DrawWorld). + function self:drawWorld() + local mx, my, mz = getMouseTargetPosition() + if not mx or not mz then return end + local unit = self:getPreferredUnit{x = mx, z = mz} + if not unit then return end + + local unitDefID = Spring.GetUnitDefID(unit) + if not unitDefID then return end + + local unitType = self.launchableTypes[unitDefID] + if not unitType then return end + + local unitDef = UnitDefs[unitDefID] + if not unitDef or not unitDef.weapons then return end + + -- Fire origin computed the same way as the Attack AoE widget (aim midpoint, + -- plus unit radius for immobile launchers) so both previews agree. + local _, _, _, ux, uy, uz = Spring.GetUnitPosition(unit, true) + if not ux then return end + if unitDef.isImmobile then + uy = uy + Spring.GetUnitRadius(unit) + end + + local weapon = unitDef.weapons[unitType.weaponId] + if not weapon then return end + + local weaponDef = WeaponDefs[weapon.weaponDef] + if not weaponDef then return end + + local dist = DistSq(mx, mz, ux, uz) + local range = weaponDef.range + if dist > range * range then return end + + -- Relocate the impact to any terrain that blocks the shot, using the same + -- trajectory model the Attack AoE widget uses when the missile is selected + -- directly, so the two previews always agree. + local ix, iy, iz = mx, my, mz + local blocked = false + if WG.AttackAoE and WG.AttackAoE.GetVlaunchImpact then + local hx, hy, hz = WG.AttackAoE.GetVlaunchImpact(weapon.weaponDef, ux, uy, uz, mx, my, mz) + if hx then + ix, iy, iz, blocked = hx, hy, hz, true + end + end + + if blocked then + drawGhostTarget(mx, my, mz, weaponDef) + end + drawBlastRadius(ix, iy, iz, weaponDef) + drawLine(ux, uy, uz, ix, iy, iz) + end + + + return self +end + +-- Silo-launched missiles (tacnuke, seismic, empmissile, napalmmissile, +-- missileslow) sit as a unit parked on their silo pad; one counts as stockpiled +-- while it exists and is still next to its silo. +local function siloMissileStockpile(unit) + if Spring.GetUnitIsDead(unit) then return 0 end + + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if not silo or Spring.GetUnitIsDead(silo) then return 0 end + + local x1, _, z1 = Spring.GetUnitPosition(silo) + local x2, _, z2 = Spring.GetUnitPosition(unit) + + if not x1 or not x2 then return 0 end + + -- A missile counts as stockpiled only while it still sits on its silo's pad. + if DistSq(x1, z1, x2, z2) > 600 then return 0 end + + return 1 +end + +-------------------------------------------------------------------------------- +-- Controllers, built from the shared missile config (Configs/missile_config.lua) +-- so the missile types live in one data table instead of duplicated classes. +-------------------------------------------------------------------------------- +local missileConfig = include("Configs/missile_config.lua") + +local launchCmdByName = { ATTACK = CMD.ATTACK, MANUALFIRE = CMD.MANUALFIRE } +local stockpileByName = { + silo = siloMissileStockpile, + engine = function(unit) return Spring.GetUnitStockpile(unit) end, +} + +-- Active launchableTypes for a config entry, honouring the Combine Eos and Scylla +-- option (launch entries flagged scope = "combine"/"separate"). +local function buildLaunchableTypes(cfg, combined) + local types = {} + for _, l in ipairs(cfg.launch) do + local active = true + if l.scope == "combine" then + active = combined + elseif l.scope == "separate" then + active = not combined + end + if active then + local ud = UnitDefNames[l.unit] + if ud then + types[ud.id] = { + launchCmd = launchCmdByName[l.cmd] or CMD.ATTACK, + weaponId = l.weaponId, + getStockpile = l.stockpile and stockpileByName[l.stockpile], + } + end + end + end + return types +end + +-- Zenith is a meteor controller, not a stockpiled missile: no count, "progress" is +-- meteors controlled / max (300), attacking rains meteors and we stop it after 3s for a +-- controlled burst. With several Zeniths the fullest is shown and preferred. +local pendingStops = {} -- {unitID, frame}: stop each Zenith 3s after it attacks +local ZENITH_STOP_DELAY_FRAMES = 90 -- 3 seconds at 30 sim fps + +local function zenithMeteors(unit) + return Spring.GetUnitRulesParam(unit, "meteorsControlled") or 0 +end + +local function zenithMeteorsMax(unit) + return Spring.GetUnitRulesParam(unit, "meteorsControlledMax") or 300 +end + +local function applyZenithBehaviour(self) + self.hideCount = true + + function self:canGiveOrder(unit) + if Spring.GetUnitIsDead(unit) then return false end + if not self.launchableTypes[Spring.GetUnitDefID(unit)] then return false end + local _, _, _, _, build = Spring.GetUnitHealth(unit) + return build == 1 + end + + function self:getCount() -- availability only; hideCount blanks the number + local n = 0 + for _ in ipairs(self:getOrderableUnits()) do n = n + 1 end + return n + end + + function self:getMaxBuildProgress() -- meteors of the fullest Zenith over its max + local best = 0 + for _, unit in ipairs(self:getOrderableUnits()) do + local ratio = zenithMeteors(unit) / zenithMeteorsMax(unit) + if ratio > best then best = ratio end + end + return best + end + + -- Prefer the Zenith controlling the most meteors; break ties by proximity. + function self:preferredUnit(unit1, unit2, params) + if not self:canGiveOrder(unit2) then return unit1 end + if not unit1 then return unit2 end + local m1, m2 = zenithMeteors(unit1), zenithMeteors(unit2) + if m2 > m1 then return unit2 end + if m1 > m2 then return unit1 end + local u1x, _, u1z = Spring.GetUnitPosition(unit1) + local u2x, _, u2z = Spring.GetUnitPosition(unit2) + if not u1x then return unit2 end + if not u2x then return unit1 end + if DistSq(params.x, params.z, u2x, u2z) < DistSq(params.x, params.z, u1x, u1z) then + return unit2 + end + return unit1 + end + + -- Attack the target, then stop after 3s (a controlled meteor burst). + function self:commandNotify(cmdID, cmdParams, cmdOptions) + if cmdID ~= self.cmd then return end + local x, y, z + if #cmdParams == 1 then + x, y, z = Spring.GetUnitPosition(cmdParams[1]) + else + x, y, z = cmdParams[1], cmdParams[2], cmdParams[3] + end + if not x then return true end + local unit = self:getPreferredUnit{x = x, z = z} + if not unit then return true end + Spring.GiveOrderToUnit(unit, CMD.ATTACK, {x, y, z}, 0) + pendingStops[#pendingStops + 1] = {unitID = unit, frame = Spring.GetGameFrame() + ZENITH_STOP_DELAY_FRAMES} + return true + end + + function self:drawWorld() -- no launch-arc preview for the meteor barrage + end +end + +-- Build one controller from a config entry. +local function buildController(cfg) + local self = missile_class() + self.key = cfg.key + self.name = cfg.unit + self.cmd = cfg.cmd + self.cmdType = CMDTYPE[cfg.cmdType or "ICON_MAP"] + self.config = cfg + self.controllerScope = cfg.controllerScope + self.launchableTypes = buildLaunchableTypes(cfg, false) + + if cfg.zenith then + applyZenithBehaviour(self) + end + + if cfg.siloBuild then + local ud = UnitDefNames[cfg.siloBuild] + self.siloBuilt = true + self.buildDefID = ud and ud.id + local realUD = self.buildDefID and UnitDefs[self.buildDefID] + local weapon = realUD and realUD.weapons and realUD.weapons[1] + local wd = weapon and WeaponDefs[weapon.weaponDef] + self.buildRange = wd and wd.range + end + + local unitDef = UnitDefNames[cfg.unit] + self.iconTexture = unitDef and ("#" .. unitDef.id) or nil + + return self +end + +local commands = {} -- [key] = controller +local orderedCommands = {} -- display order (from config) +for _, cfg in ipairs(missileConfig) do + local controller = buildController(cfg) + commands[cfg.key] = controller + orderedCommands[#orderedCommands + 1] = controller +end + +-- Unit defs whose creation/completion/destruction can change the launchable set +-- (the silo missiles and the units that hold them). Used to refresh immediately +-- on the relevant unit events instead of waiting for the next poll, so the +-- launch button appears promptly when a missile starts building. +local relevantUnitDefs = {} +for _, cfg in ipairs(missileConfig) do + for _, l in ipairs(cfg.launch) do + local ud = UnitDefNames[l.unit] + if ud then relevantUnitDefs[ud.id] = true end + end +end + +local UPDATE_FREQUENCY = 0.25 +local timer = UPDATE_FREQUENCY + 1 +local wasEmptySelection = false + +-- The launch command to re-arm on the next frame. The engine deactivates a command +-- once its order is issued (unless shift is held), so after firing we re-select it to +-- keep launch mode sticky -- but only while nothing else has taken over, so switching +-- to another command or closing the tab (both of which change the active command) +-- ends it naturally. +local reArmCmd = false + +-- cmdID -> controller lookup, plus shift-edge state, used to keep launch mode sticky +-- across a shift-release (see Update). The engine keeps a shift-issued command active +-- only while shift is held and drops it on release; we re-arm it so releasing shift +-- behaves like the sticky no-shift fire. +local commandByCmd = {} +for _, command in pairs(commands) do + commandByCmd[command.cmd] = command +end +local wasShift = false +local prevActiveMissileCommand = false +local wasTabOpen = false -- launcher tab open state, to deselect the command when it closes + +-- Tracks the Combine Eos and Scylla option so launchableTypes and the Scylla button's +-- visibility are rebuilt only when it changes (see refreshCombine in Update). +local lastCombined = nil + +-------------------------------------------------------------------------------- +-- Building missiles from the launch buttons +-------------------------------------------------------------------------------- +-- The silo-built types can also be produced from the launcher: left-click the button +-- to arm the type as normal, then Alt+click the map to build one at the nearest silo +-- instead of launching. Plain left-click on the map still launches. Build is on Alt, +-- never plain left-click, so a missile finishing the instant you click can never turn +-- an intended build into an accidental launch. +local SILO_NAME = "staticmissilesilo" +local siloDefID = UnitDefNames[SILO_NAME] and UnitDefNames[SILO_NAME].id +local siloIconTexture = siloDefID and ("#" .. siloDefID) +local SILO_CAPACITY = 4 -- missile_silo_capacity in staticmissilesilo.lua +local SILO_SEARCH = 48 -- half-width of the pad scan (matches cmd_missile_silo) + +local function getMyTeamSilos() + local silos = {} + if siloDefID then + for _, unitID in ipairs(Spring.GetTeamUnits(Spring.GetMyTeamID()) or {}) do + if Spring.GetUnitDefID(unitID) == siloDefID then + silos[#silos + 1] = unitID + end + end + end + return silos +end + +-- Arm a command as the active launch (bypasses action()'s ready-count guard, so a +-- silo-built type can be selected with nothing ready yet in order to build). +local function armCommand(command) + local cmdIndex = Spring.GetCmdDescIndex(command.cmd) + if cmdIndex then + local alt, ctrl, meta, shift = Spring.GetModKeyState() + Spring.SetActiveCommand(cmdIndex, 1, true, false, alt, ctrl, meta, shift) + end +end + +-- Arm a default missile when the launcher is opened (used by the core selector's launch +-- button). Prefer the first type with a missile ready; otherwise, if a silo exists, arm +-- the first silo-built type (Eos) so it is selected by default and ready to build. +WG.SelectDefaultMissile = function() + for _, command in ipairs(orderedCommands) do + if command:getCount() > 0 then + armCommand(command) + return true + end + end + if #getMyTeamSilos() > 0 then + for _, command in ipairs(orderedCommands) do + if command.siloBuilt then + armCommand(command) + return true + end + end + end + return false +end + +-- True while the launcher is open (its hidden tab is revealed). The core selector uses +-- this to highlight the launch button. The tab stays open through firing (no +-- returnOnClick) and closes when the launcher is dismissed -- switching tabs, selecting +-- a unit, right-click, or toggling the button -- so this alone tracks "launcher active" +-- without falsely staying true when an armed command lingers after a dismissal. +WG.IsLaunchActive = function() + return (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false +end + +-- Missiles that have finished building on this silo's pads (build progress complete). +-- These occupy a pad and are not in the build queue any more. +local function siloFinishedCount(siloID, sx, sz) + local finished = 0 + for _, mID in ipairs(Spring.GetUnitsInRectangle(sx - SILO_SEARCH, sz - SILO_SEARCH, sx + SILO_SEARCH, sz + SILO_SEARCH) or {}) do + if Spring.GetUnitRulesParam(mID, "missile_parentSilo") == siloID then + local _, _, _, _, buildProgress = Spring.GetUnitHealth(mID) + if buildProgress and buildProgress >= 1 then + finished = finished + 1 + end + end + end + return finished +end + +-- Total missiles queued at a silo (the currently building one plus anything waiting). +local function siloBuildQueueLength(siloID) + local queue = Spring.GetFullBuildQueue(siloID) + local n = 0 + if queue then + for i = 1, #queue do + local block = queue[i] + if type(block) == "table" then + for _, count in pairs(block) do + n = n + count + end + end + end + end + return n +end + +-- Pick the nearest silo (to the Alt-click, else the screen centre) that still has spare +-- capacity: finished missiles on pads plus everything queued must be below the silo's +-- capacity. Returns siloID, sx, sz, or nil when every silo is full -- so a build is never +-- queued past capacity (which would produce a stuck, unusable missile). +local function chooseBuildSilo(refX, refZ) + local cx, cz = refX, refZ + if not cx then + local vsx, vsy = Spring.GetViewGeometry() + local _, coords = Spring.TraceScreenRay(vsx * 0.5, vsy * 0.5, true) + cx, cz = coords and coords[1], coords and coords[3] + end + + local bestSilo, bestD, bestX, bestZ + for _, siloID in ipairs(getMyTeamSilos()) do + local sx, _, sz = Spring.GetUnitPosition(siloID) + if sx then + local committed = siloFinishedCount(siloID, sx, sz) + siloBuildQueueLength(siloID) + if committed < SILO_CAPACITY then + local d = cx and ((sx - cx) * (sx - cx) + (sz - cz) * (sz - cz)) or 0 + if not bestD or d < bestD then + bestSilo, bestD, bestX, bestZ = siloID, d, sx, sz + end + end + end + end + return bestSilo, bestX, bestZ +end + +-- Build one missile of this type at a silo near (refX, refZ) -- the Alt-click point, +-- so clicking near a silo builds there. +local function buildMissile(command, refX, refZ) + if not (command.siloBuilt and command.buildDefID) then return end + local silo = chooseBuildSilo(refX, refZ) + if silo then + -- No modifier queues exactly one; OPT_SHIFT would multiply the order x5. + Spring.GiveOrderToUnit(silo, -command.buildDefID, {}, 0) + end +end + +-- True if the click that issued the armed launch is a build (Alt held), not a launch. +local function isBuildClick(cmdOptions) + return cmdOptions and cmdOptions.alt +end + +-- While a silo-built launch is armed and Alt is held, preview which silo will build the +-- missile (the one nearest the cursor) and that missile's range, so the player sees +-- where an Alt-click will produce it and how far it reaches. +local function drawBuildPreview() + local _, activeCmd = Spring.GetActiveCommand() + local command = activeCmd and commandByCmd[activeCmd] + if not (command and command.siloBuilt) then return end + local alt = Spring.GetModKeyState() + if not alt then return end + + local mx, _, mz = getMouseTargetPosition() + if not mx then return end + + local _, sx, sz = chooseBuildSilo(mx, mz) + if not (sx and command.buildRange) then return end + + local sy = Spring.GetGroundHeight(sx, sz) or 0 + glDepthTest(false) + glColor(0.3, 1, 0.3, 0.8) + glLineWidth(2) + glDrawGroundCircle(sx, sy, sz, 90, 32) -- the building silo + glColor(0.3, 1, 0.3, 0.5) + glLineWidth(1.5) + glDrawGroundCircle(sx, sy, sz, command.buildRange, 64) -- the missile's range + glColor(1, 1, 1, 1) + glLineWidth(1) + glDepthTest(true) +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:CommandsChanged() + for _, command in pairs(commands) do + command:commandsChanged() + end +end + +-- Rebuild launchableTypes and Scylla's visibility when the Combine option changes. +local function refreshCombine() + local combined = options.combineEosScylla.value + if combined == lastCombined then + return + end + lastCombined = combined + for _, command in ipairs(orderedCommands) do + command.launchableTypes = buildLaunchableTypes(command.config, combined) + -- The Scylla button (separate-only) is hidden while Eos and Scylla are combined. + command.hidden = (command.controllerScope == "separate" and combined) + end + -- Re-register commands so the descriptor hidden flags update. + Spring.ForceLayoutUpdate() +end + +function widget:Update(dt) + refreshCombine() + + -- Stop each Zenith 3s after its attack order so a meteor barrage is a controlled + -- burst. Checked every frame against the sim clock (so it is unaffected by pause). + if pendingStops[1] then + local frame = Spring.GetGameFrame() + for i = #pendingStops, 1, -1 do + if frame >= pendingStops[i].frame then + if not Spring.GetUnitIsDead(pendingStops[i].unitID) then + Spring.GiveOrderToUnit(pendingStops[i].unitID, CMD.STOP, {}, 0) + end + table.remove(pendingStops, i) + end + end + end + + -- Keep launch mode sticky after a shot: re-arm the fired command unless something + -- else is now active (the player switched command, or the tab closed and cleared + -- it). Runs every frame, before the poll throttle, so there is no cursor flicker. + if reArmCmd then + local command = reArmCmd + reArmCmd = false + local _, activeCmd = Spring.GetActiveCommand() + -- Re-arm if nothing else took over and the type is still usable: it has a missile + -- ready to launch, or (silo-built) a silo exists so more can be Alt-built. Firing + -- the last one of a launch-only type deselects naturally. This only fires after a + -- deliberate launch (or shift-release), so it is kept even with units selected -- + -- the auto-arm-on-open is guarded separately in SelectDefaultMissile. + local usable = command:getCount() > 0 or (command.siloBuilt and #getMyTeamSilos() > 0) + if not activeCmd and usable then + local cmdIndex = Spring.GetCmdDescIndex(command.cmd) + if cmdIndex then + local alt, ctrl, meta, shift = Spring.GetModKeyState() + Spring.SetActiveCommand(cmdIndex, 1, true, false, alt, ctrl, meta, shift) + end + end + end + + -- On the shift down->up edge, if a launch command was the active one, schedule a + -- re-arm for next frame. The engine drops a shift-issued command when shift is + -- released; re-arming keeps launch mode sticky. The re-arm above no-ops if the + -- command is in fact still active (only shift was released, no shift-fire), so this + -- only takes effect when the release actually deselected it. + local _, activeCmd = Spring.GetActiveCommand() + local activeMissileCommand = activeCmd and commandByCmd[activeCmd] or false + local shift = select(4, Spring.GetModKeyState()) + if wasShift and not shift then + local held = activeMissileCommand or prevActiveMissileCommand + if held then + reArmCmd = held + end + end + prevActiveMissileCommand = activeMissileCommand + wasShift = shift + + -- When the launcher tab closes (switching tabs, selecting a unit, right-click, + -- toggling the button), deselect any armed launch command too, so launch mode does + -- not linger with the launcher shut. Catches every close path in one place. + local tabOpen = (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen + and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false + if wasTabOpen and not tabOpen then + reArmCmd = false + if activeMissileCommand then + Spring.SetActiveCommand(nil) + end + end + wasTabOpen = tabOpen + + timer = timer + dt + if timer < UPDATE_FREQUENCY then + return + end + timer = 0 + + local changed = false + local activeIcons = {} + + -- Show the silo-built types (and thus the launcher) whenever a silo exists, even + -- with no missiles, so the launcher can be used to build them. + local hasSilo = (#getMyTeamSilos() > 0) + + for _, command in ipairs(orderedCommands) do + local count = command:getCount() + local buildProgress = command:getMaxBuildProgress() + + -- Only show a type when it has something ready or building. (When there is nothing + -- to show at all, the silo icon is added below so the launcher still appears.) + local include = (count >= 1 or buildProgress > 0) + if command.iconTexture and include then + -- Zenith carries no count (hideCount); its progress is meteors / max. + local displayCount = command.hideCount and 0 or count + activeIcons[#activeIcons + 1] = {icon = command.iconTexture, count = displayCount, progress = buildProgress} + end + + -- Count string shown on the button (e.g. "x3"), empty when none stockpiled or + -- when the type hides its count (Zenith). Drawn by the integral menu via the + -- command's name field (see DRAW_NAME_COMMANDS / commandDisplayConfig.drawName). + local displayName = "" + if count > 0 and not command.hideCount then + displayName = "x" .. count + end + + -- Factory-style build progress bar on the button. + if WG.IntegralMenu and WG.IntegralMenu.SetCommandProgress then + WG.IntegralMenu.SetCommandProgress(command.cmd, buildProgress) + end + + -- Keep silo-built types enabled while a silo exists so the type can be armed with + -- nothing ready (a disabled button cannot be clicked), then Alt-clicked to build. + local disabled = (count == 0) and not (command.siloBuilt and hasSilo) + if command.displayName ~= displayName or command.disabled ~= disabled then + command.displayName = displayName + command.disabled = disabled + changed = true + end + end + + -- Nothing ready or building, but a silo exists: show the silo itself so the launcher + -- stays visible (and hints you can build missiles there). No count, no progress. + if #activeIcons == 0 and hasSilo and siloIconTexture then + activeIcons[1] = {icon = siloIconTexture, count = 0, progress = 0, isSilo = true} + end + + -- Export active-missile icons for the tab badge. + WG.missileActiveIcons = activeIcons + + -- The integral menu only re-reads custom commands on CommandsChanged, which + -- the command menu pipeline does not run on its own while nothing is selected. + -- Force a rebuild when the shown count/progress changed, or once when the + -- selection first becomes empty, so the missiles tab stays available. + local emptySelection = (Spring.GetSelectedUnitsCount() == 0) + if changed or (emptySelection and not wasEmptySelection) then + Spring.ForceLayoutUpdate() + end + wasEmptySelection = emptySelection +end + +-- Run the next Update on the following frame rather than waiting out the poll +-- interval, so unit changes are reflected right away. +local function refreshSoon() + timer = UPDATE_FREQUENCY +end + +function widget:UnitCreated(unitID, unitDefID, unitTeam) + if unitTeam == Spring.GetMyTeamID() and relevantUnitDefs[unitDefID] then + refreshSoon() + end +end + +function widget:UnitFinished(unitID, unitDefID, unitTeam) + if unitTeam == Spring.GetMyTeamID() and relevantUnitDefs[unitDefID] then + refreshSoon() + end +end + +function widget:UnitDestroyed(unitID, unitDefID, unitTeam) + if unitTeam == Spring.GetMyTeamID() and relevantUnitDefs[unitDefID] then + refreshSoon() + end +end + +function widget:CommandNotify(cmdID, cmdParams, cmdOptions) + -- Alt+click on the map with a silo-built type armed builds one (at the silo nearest + -- the click) instead of launching. Keeps the type armed so you can keep building. + local command = commandByCmd[cmdID] + if command and command.siloBuilt and isBuildClick(cmdOptions) then + local x, z + if #cmdParams >= 3 then + x, z = cmdParams[1], cmdParams[3] + elseif #cmdParams == 1 then + local ux, _, uz = Spring.GetUnitPosition(cmdParams[1]) + x, z = ux, uz + end + buildMissile(command, x, z) + reArmCmd = command + return true + end + + -- Single table lookup instead of scanning every controller (the per-controller + -- gate was just `cmdID == self.cmd`). + if command and command:commandNotify(cmdID, cmdParams, cmdOptions) then + -- Re-arm this launch command next frame so firing stays sticky without shift. + reArmCmd = command + return true + end +end + +-- Selecting units dismisses the launcher: stop the sticky re-arm and drop any armed +-- launch command, so the player's clicks act on their units instead of firing. Pressing +-- the launch selector and firing do not change the selection, so they are unaffected. +function widget:SelectionChanged(selectedUnits) + if selectedUnits and #selectedUnits > 0 then + reArmCmd = false + local _, activeCmd = Spring.GetActiveCommand() + if activeCmd and commandByCmd[activeCmd] then + Spring.SetActiveCommand(nil) + end + end +end + +-- Fully close the launcher: stop the sticky re-arm, drop any armed launch command, +-- and close the tab. Exposed so the core-selector launch button can toggle it closed. +local function dismissLauncher() + reArmCmd = false + local _, activeCmd = Spring.GetActiveCommand() + if activeCmd and commandByCmd[activeCmd] then + Spring.SetActiveCommand(nil) + end + if WG.IntegralMenu and WG.IntegralMenu.CloseHiddenTab then + WG.IntegralMenu.CloseHiddenTab() + end +end +WG.DismissLauncher = dismissLauncher + +-- Right-click while the launcher is open closes it (and consumes the click, so it acts +-- purely as "close the launcher" rather than also issuing an order). +function widget:MousePress(mx, my, button) + if button == 3 and WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen + and WG.IntegralMenu.IsHiddenTabOpen("missiles") then + dismissLauncher() + return true + end + return false +end + + +function widget:DrawWorld() + -- Only the active command draws its preview; look it up once instead of scanning + -- every controller and calling GetActiveCommand in each. + local _, activeCmd = Spring.GetActiveCommand() + local command = activeCmd and commandByCmd[activeCmd] + if command then + command:drawWorld() + end + drawBuildPreview() +end +