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/epicmenu_conf.lua b/LuaUI/Configs/epicmenu_conf.lua index d6835d0c63..41c9b051b9 100644 --- a/LuaUI/Configs/epicmenu_conf.lua +++ b/LuaUI/Configs/epicmenu_conf.lua @@ -222,6 +222,7 @@ confdata.subMenuIcons = { ['Settings/HUD Panels/Stats Graph'] = imgPath..'graphs_icon.png', ['Settings/HUD Panels/Global Commands'] = imgPath..'planetQuestion.png', ['Settings/HUD Panels/Nuke Warning'] = imgPath..'nuke_button_48.png', + ['Settings/HUD Panels/Missile Launcher'] = 'icons/cruisemissile.dds', ['Settings/HUD Panels/Extras'] = imgPath..'plus_green.png', ['Settings/Spectating/Action Tracking Camera'] = imgPath..'epicmenu/video_camera.png', 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/cmd_layout_handler.lua b/LuaUI/Widgets/cmd_layout_handler.lua index 04b76da040..5449e82809 100644 --- a/LuaUI/Widgets/cmd_layout_handler.lua +++ b/LuaUI/Widgets/cmd_layout_handler.lua @@ -151,9 +151,14 @@ local function LayoutHandler(xIcons, yIcons, cmdCount, commands) reParamsCmds[cc.cmdDescID] = cc.params end --// remove api keys (custom keys are prohibited in the engine handler) + --// disabled is a boolean read by the integral menu from the raw + --// customCommands list; the engine's descriptor parser rejects any + --// non-string/table value and logs "GetLuaCmdDescList() bad entry", so it + --// must be stripped from the copy sent to the engine. cc.pos = nil cc.cmdDescID = nil cc.params = nil + cc.disabled = nil customCmds[#customCmds+1] = cc end diff --git a/LuaUI/Widgets/gui_attack_aoe.lua b/LuaUI/Widgets/gui_attack_aoe.lua index b27df4f10e..8ac0d28f29 100644 --- a/LuaUI/Widgets/gui_attack_aoe.lua +++ b/LuaUI/Widgets/gui_attack_aoe.lua @@ -215,6 +215,29 @@ 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 + +-- unitDef may be nil: the weapon-only fields (type, range, aoe, ee, vlaunch, +-- scatter) need no unitDef, so other widgets can query a weapon in isolation. The +-- few unitDef-derived fields (cost, mobile, and the AircraftBomb speed/altitude) +-- fall back to neutral defaults when it is absent. local function getWeaponInfo(weaponDef, unitDef) local retData @@ -222,7 +245,7 @@ local function getWeaponInfo(weaponDef, unitDef) local spray = (weaponDef.customParams and weaponDef.customParams.gui_sprayangle) or weaponDef.sprayAngle local scatter = weaponDef.accuracy + spray local aoe = tonumber(weaponDef.customParams.gui_aoe) or weaponDef.damageAreaOfEffect - local cost = unitDef.metalCost + local cost = unitDef and unitDef.metalCost or 0 local waterWeapon = weaponDef.waterWeapon local ee = tonumber(weaponDef.customParams.gui_ee) or weaponDef.edgeEffectiveness if (weaponDef.cylinderTargetting >= 100) then @@ -253,7 +276,7 @@ local function getWeaponInfo(weaponDef, unitDef) retData = {type = "direct", scatter = scatter, range = weaponDef.range} end elseif (weaponType == "AircraftBomb") then - retData = {type = "dropped", scatter = scatter, v = unitDef.speed, h = unitDef.cruiseAltitude, salvoSize = weaponDef.salvoSize, salvoDelay = weaponDef.salvoDelay} + retData = {type = "dropped", scatter = scatter, v = unitDef and unitDef.speed, h = unitDef and unitDef.cruiseAltitude, salvoSize = weaponDef.salvoSize, salvoDelay = weaponDef.salvoDelay} elseif (weaponType == "StarburstLauncher") then if (weaponDef.tracks) then retData = {type = "tracking", range = weaponDef.range} @@ -277,20 +300,9 @@ 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.mobile = not (unitDef and unitDef.isImmobile) retData.waterWeapon = waterWeapon retData.ee = ee @@ -462,18 +474,30 @@ end --aoe -------------------------------------------------------------------------------- +-- The nested falloff ring stack shared by the in-widget AoE draw and the exported +-- preview, so both render an identical footprint (same radii, same edge falloff, +-- same animated pulse via GetSecondPart). dist is the camera distance used for the +-- line width; colour is passed component-wise so callers can tint it. +local function DrawAoERingStack(tx, ty, tz, aoe, ee, alphaMult, offset, dist, r, g, b, a) + glLineWidth(math.max(0.05, aoeLineWidthMult * aoe / dist)) + for i = 1, numAoECircles do + local proportion = i / (numAoECircles + 1) + local alpha = a * (1 - proportion) / (1 - proportion * ee) * (1 - GetSecondPart(offset or 0)) * (alphaMult or 1) + glColor(r, g, b, alpha) + DrawCircle(tx, ty, tz, aoe * proportion) + end + glColor(1, 1, 1, 1) + glLineWidth(1) +end + local function DrawAoE(tx, ty, tz, aoe, ee, alphaMult, offset, circleMode) - glLineWidth(math.max(0.05, aoeLineWidthMult * aoe / mouseDistance)) - if not circleMode then - for i = 1, numAoECircles do - local proportion = i / (numAoECircles + 1) - local radius = aoe * proportion - local alpha = aoeColor[4] * (1 - proportion) / (1 - proportion * ee) * (1 - GetSecondPart(offset or 0)) * (alphaMult or 1) - glColor(aoeColor[1], aoeColor[2], aoeColor[3], alpha) - DrawCircle(tx, ty, tz, radius) - end - elseif circleMode == "cloaker" then + DrawAoERingStack(tx, ty, tz, aoe, ee, alphaMult, offset, mouseDistance, aoeColor[1], aoeColor[2], aoeColor[3], aoeColor[4]) + return + end + + glLineWidth(math.max(0.05, aoeLineWidthMult * aoe / mouseDistance)) + if circleMode == "cloaker" then for i = 1, 3 do local proportion = (i + 17) / 20 local radius = aoe * proportion @@ -487,6 +511,27 @@ 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 THE SAME ring stack the in-widget draw +-- uses -- identical radii, edge falloff and animated pulse -- so the launch preview +-- and the stock force-fire preview match exactly. `color` (optional {r,g,b[,a]}) +-- tints the rings; `alphaMult` (optional) scales overall opacity. The line width is +-- derived from the live camera distance, so callers need no per-frame setup and it +-- does not depend on this widget's DrawWorld having run first. +local function DrawAoEPreview(tx, ty, tz, aoe, ee, color, alphaMult) + if not aoe or aoe <= 0 then + return + end + local cx, cy, cz = GetCameraPosition() + local dx, dy, dz = cx - tx, cy - ty, cz - tz + local camDist = max(1, sqrt(dx*dx + dy*dy + dz*dz)) + 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 a = (color and color[4]) or aoeColor[4] + DrawAoERingStack(tx, ty, tz, aoe, ee or 1, alphaMult, 0, camDist, r, g, b, a) +end + -------------------------------------------------------------------------------- --dgun/noexplode -------------------------------------------------------------------------------- @@ -939,6 +984,34 @@ local function CalculateVlaunchImpact(info, fx, fy, fz, tx, ty, tz) return false end +-------------------------------------------------------------------------------- +-- Shared interface (used by the missile launch preview widget) +-------------------------------------------------------------------------------- + +local externalFunctions = {} + +-- Weapon info by weaponDefID, built from the same getWeaponInfo used for units and +-- cached here. No unitDef is needed for the fields the launch preview reads +-- (vlaunch, range, aoe, ee), so other widgets reuse this widget's trajectory and +-- AoE model instead of maintaining a parallel copy. +local sharedWeaponInfo = {} +function externalFunctions.GetWeaponInfo(weaponDefID) + local info = sharedWeaponInfo[weaponDefID] + if info == nil then + local wd = WeaponDefs[weaponDefID] + info = (wd and getWeaponInfo(wd, nil)) or false + sharedWeaponInfo[weaponDefID] = info + end + return info or nil +end + +-- Terrain impact point (x, y, z) of a vlaunch (starburst) shot, or false if it +-- reaches the target unobstructed. Pass info from GetWeaponInfo (only meaningful +-- when info.vlaunch is set). +externalFunctions.CalculateVlaunchImpact = CalculateVlaunchImpact + +externalFunctions.DrawAoEPreview = DrawAoEPreview + -------------------------------------------------------------------------------- --Main draw -------------------------------------------------------------------------------- @@ -1017,6 +1090,36 @@ local function drawForUnit(unitID, tx, ty, tz, targetIsGround, cmd, info, rangeR end end +-- Draw the exact attack preview a selected unit shows for a target -- scatter, AoE, +-- range leash and vlaunch relocation -- using the same per-unit info the stock +-- force-fire preview uses. Lets widgets that issue attack-like commands (e.g. the +-- missile launcher's Zenith meteor barrage) render identically instead of +-- reimplementing the scatter model. showRange also draws the firing unit's range +-- ring (in the same style as the dgun/manualfire ring): useful for launcher-style +-- UIs where the firing unit is not selected, so its reach is otherwise not shown. +function externalFunctions.DrawUnitAttackPreview(unitID, tx, ty, tz, targetIsGround, showRange) + if not unitID then + return + end + local unitDefID = spGetUnitDefID(unitID) + local info = unitDefID and aoeDefInfo[unitDefID] + if not info then + return + end + if showRange and info.range then + local _, _, _, fx, fy, fz = GetUnitPosition(unitID, true) + if fx then + local rangeMult = (Spring.GetUnitRulesParam(unitID, "rangeMult") or 1) + glColor(1, 0.3, 0.3, 0.6) + glLineWidth(2) + glDrawGroundCircle(fx, fy, fz, info.range * rangeMult, circleDivs) + glColor(1, 1, 1, 1) + glLineWidth(1) + end + end + drawForUnit(unitID, tx, ty, tz, targetIsGround, CMD_ATTACK, info, false) +end + -------------------------------------------------------------------------------- --callins -------------------------------------------------------------------------------- @@ -1027,10 +1130,12 @@ function widget:Initialize() aoeDefInfo[unitDefID], dgunInfo[unitDefID], extraDrawRangeDefInfo[unitDefID] = SetupUnit(unitDef) end SetupDisplayLists() + WG.AttackAoE = externalFunctions 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..f1dfdfc15a 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -79,6 +79,14 @@ 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 + +-- Forward declaration: the missile option OnChange handlers (defined in the options +-- table below) rebuild the button list via ClearData, which is defined much later. +local ClearData + local factoryList = {} local commanderList = {} local idleCons = {} -- [unitID] = true @@ -98,6 +106,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 +129,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 +223,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 +307,35 @@ 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, + -- Rebuild the button list so existing silo buttons appear/disappear at once + -- (ClearData re-scans owned units; the old list is disposed next Update, same + -- as on a team change). + OnChange = function() + if buttonList then + ClearData() + end + end, + }, + 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, + -- Toggling off should remove an existing button at once (it otherwise only + -- self-removes when the last missile is gone); rebuild to apply immediately. + OnChange = function() + if buttonList then + ClearData() + end + end, + }, 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 +656,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 +785,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 +885,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 +929,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 +1074,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 +1085,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 +1094,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 +1251,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 +1510,249 @@ 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 + -- A single cell (one missile, or the silo placeholder) is inset 5% to + -- match the normal buttons' icon padding, so its icon and its count + -- label line up exactly with the idle-con / factory buttons (whose image + -- is inset 5% inside the button). Grid cells (2+ missiles) stay flush so + -- the tiles pack tightly. + if singleCell or icons[i].isSilo then + 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 @@ -1727,7 +2069,9 @@ local function InitializeControls() CheckHide() end -local function ClearData() +-- Assigns the forward-declared local (see top of file) so the missile option +-- OnChange handlers can call it. +function ClearData() factoryList = {} commanderList = {} idleCons = {} @@ -1747,7 +2091,7 @@ local function ClearData() buttonList = GetButtonListHandler(mainBackground) buttonList.AddButton(CONSTRUCTOR_BUTTON_ID, GetConstructorButton(buttonHolder)) InitializeUnits() - + buttonList.SetImagesVisible(false) end @@ -1762,7 +2106,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 +2297,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..2d9b100d92 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -125,6 +125,13 @@ 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 @@ -132,6 +139,11 @@ 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 +662,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 +2103,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 +2120,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 +2139,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 +2291,11 @@ local function ProcessAllCommands(commands, customCommands) local factoryUnitID, factoryUnitDefID, fakeFactory, selectedUnitCount = GetSelectionValues() local unitMobilePanelSize = GetUnitMobilePanelSize(commands, factoryUnitDefID) + -- A hidden tab (the missiles Launch tab) stays open until the user presses a tab + -- button or a widget closes it via the API. The integral menu deliberately does + -- not react to selection changes or unit deaths here; any such policy belongs to + -- the widget that opened the tab (see the missile widget's launcher option). + selectionIndex = selectionIndex + 1 for i = 1, #commandPanels do @@ -2319,10 +2357,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 +2377,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 +2410,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 +2513,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 +2535,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 +2574,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 +2738,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 +2782,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..20a2d7f5f2 --- /dev/null +++ b/LuaUI/Widgets/missile_command_center.lua @@ -0,0 +1,1197 @@ +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +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', 'autoLauncher'} +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, + }, + autoLauncher = { + name = 'Auto-arm and auto-close launcher', + desc = 'When enabled, opening the missile launcher immediately arms the default missile, and selecting units closes the launcher (the original behaviour). ' + .. 'When disabled (default), opening the launcher just shows the Launch tab: it stays open until you switch tabs or close it, and no missile is armed until you pick one.', + 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 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, including the shared animated pulse. +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) +end + +-- Range ring at the firing unit, matching the Attack AoE widget's dgun/manualfire +-- ring style. A launcher fires without the unit selected, so its reach is otherwise +-- not shown; the ring makes it visible. +local function drawRangeRing(ux, uy, uz, range) + glColor(1, 0.3, 0.3, 0.6) + glLineWidth(2) + glDrawGroundCircle(ux, uy, uz, range, circleDivs) + glColor(1, 1, 1, 1) + glLineWidth(1) +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 + +-- Terrain impact of a vlaunch shot for weapon `weaponDefID`, fired from (fx,fy,fz) +-- at (tx,ty,tz), via the Attack AoE widget's shared weapon model. Returns the impact +-- x,y,z when the shot slams into terrain short of the target, or nil otherwise (clear +-- shot, non-vlaunch weapon, or the Attack AoE widget not loaded). CalculateVlaunchImpact +-- returns false for a clear shot, which is nil-ified here. +local function getVlaunchImpact(weaponDefID, fx, fy, fz, tx, ty, tz) + if not (WG.AttackAoE and WG.AttackAoE.GetWeaponInfo) then return nil end + local info = WG.AttackAoE.GetWeaponInfo(weaponDefID) + if not (info and info.vlaunch) then return nil end + local hx, hy, hz = WG.AttackAoE.CalculateVlaunchImpact(info, fx, fy, fz, tx, ty, tz) + if hx then return hx, hy, hz end + return nil +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +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) + 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 = 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 + + -- Range ring at the launcher, drawn even when aiming past it so the reach is + -- clear; the blast/line below are suppressed once out of range. + local range = weaponDef.range * (Spring.GetUnitRulesParam(unit, "rangeMult") or 1) + drawRangeRing(ux, uy, uz, range) + + local dist = DistSq(mx, mz, ux, uz) + 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 + local hx, hy, hz = getVlaunchImpact(weapon.weaponDef, ux, uy, uz, mx, my, mz) + if hx then + ix, iy, iz, blocked = hx, hy, hz, true + 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 + + -- Meteor barrage: draw the same preview a selected Zenith shows for an attack -- + -- the meteors' wide scatter dwarfs each impact's small AoE -- by invoking the + -- Attack AoE widget directly rather than reimplementing the scatter model. + function self:drawWorld() + if not (WG.AttackAoE and WG.AttackAoE.DrawUnitAttackPreview) then return end + local mx, my, mz, targetIsGround = getMouseTargetPosition() + if not mx or not mz then return end + + local unit = self:getPreferredUnit{x = mx, z = mz} + if not unit then return end + + -- showRange: the Zenith is fired from the launcher without being selected, so + -- draw its range ring too (a selected unit would show it via other means). + WG.AttackAoE.DrawUnitAttackPreview(unit, mx, my, mz, targetIsGround, true) + + -- No launch arc for a single Zenith (meteors rain from above, so a line from the + -- unit would be misleading). With multiple Zeniths, draw a line from the one that + -- will actually fire so it is clear which barrage is being aimed. + if self:getCount() > 1 then + local ux, uy, uz = Spring.GetUnitPosition(unit) + if ux then + drawLine(ux, uy, uz, mx, my, mz) + end + end + 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. +-- Only the opt-in "Auto-arm" behaviour arms on open; by default opening just shows the +-- Launch tab and nothing is armed until the player picks a missile. +WG.SelectDefaultMissile = function() + if not options.autoLauncher.value then + return false + end + 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 drops 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). Whether it also closes the launcher tab is opt-in +-- (autoLauncher); by default the tab stays open until a tab press or explicit close. +function widget:SelectionChanged(selectedUnits) + if not (selectedUnits and #selectedUnits > 0) then + return + end + reArmCmd = false + local _, activeCmd = Spring.GetActiveCommand() + if activeCmd and commandByCmd[activeCmd] then + Spring.SetActiveCommand(nil) + end + if options.autoLauncher.value and WG.IntegralMenu and WG.IntegralMenu.CloseHiddenTab then + WG.IntegralMenu.CloseHiddenTab() + 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 +