From 77243b7cf6429ba752a826a28e9024153aaf675e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:33:53 +0000 Subject: [PATCH 01/31] Add missile widget from ZeroK-widgets Copy missle_command_center.lua widget to enable missile command center functionality. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 522 ++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 LuaUI/Widgets/missle_command_center.lua diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua new file mode 100644 index 0000000000..8ba9d4413b --- /dev/null +++ b/LuaUI/Widgets/missle_command_center.lua @@ -0,0 +1,522 @@ +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:GetInfo() + return { + name = "command center: missle", + desc = "Add missle commands to command center", + author = "Amnykon", + date = "2021-07-30", + license = "GNU GPL, v2 or later", + layer = 0, + handler = true, + enabled = false, + } +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +VFS.Include(LUAUI_DIRNAME.."Widgets/lib/floatingCommand.lua") +VFS.Include(LUAUI_DIRNAME.."Widgets/Utilities/engine_blast_radius.lua") + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +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 + +local function assign(table, field, value) + table[field] = value + return value +end + +local function distance3(x1, y1, z1, x2, y2, z2) + return (x1-x2)*(x1-x2)+(y1-y2)*(y1-y2)+(z1-z2)*(z1-z2) +end + +local function distance(x1,z1,x2,z2) + return (x1-x2)*(x1-x2)+(z1-z2)*(z1-z2) +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +local function missle_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 unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] + local numStockpiled = unitType.getStockpile(unit) + + local numQueued = 0 + if (numStockpiled or 0) ~= 0 then + local cmdQueue = Spring.GetUnitCommands(unit, numStockpiled); + + for _, cmd in ipairs(cmdQueue) do + if cmd.id == unitType.launchCmd then numQueued = numQueued + 1 end + 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 type = self.launchableTypes[Spring.GetUnitDefID(unit)] + if type then + count = count + + type.getStockpile(unit) + - self:getNumberOfQueueLaunches(unit) + end + end + end + return count + 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 + + function self:perferedUnit(unit1, unit2, params) + local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) + local unit2Dist = distance(params.x, params.z, unit2x, unit2z) + + local type2 = self.launchableTypes[Spring.GetUnitDefID(unit2)] + local weaponDef2 = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit2)].weapons[type2.weaponId].weaponDef] + + 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)] + local weaponDef1 = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit1)].weapons[type1.weaponId].weaponDef] + + 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 = distance(params.x, params.z, unit1x, unit1z) + + local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) + local unit2Dist = distance(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:initialize() + floatingCommand{ + name = "launch " .. self.name, + x = self.x, + y = self.y, + action = function() + self:action() + end, + contents = { + assign(self, "bottomLabel", + WG.Chili.Label:New { + x = 0, + y = 0, + right = 5, + bottom = 5, + align = "right", + valign = "bottom", + caption = caption, + fontSize = 16, + autosize = false, + fontShadow = true, + } + ), + WG.Chili.Image:New { + x = "5%", + y = "5%", + right = "5%", + bottom = "5%", + file = "#" .. UnitDefNames[self.name].id, + keepAspect = false, + }, + }, + } + end + + function self:getPerferedUnit(params) + local units = self:getOrderableUnits() + + params.selectedUnits = {} + for _, unit in ipairs(Spring.GetSelectedUnits() or {}) do + params.selectedUnits[unit] = true + end + + local perferedUnit + + for _, unitID in ipairs(units) do + if self:canGiveOrder(unitID) then + perferedUnit = self:perferedUnit(perferedUnit, unitID, params) + end + end + + return perferedUnit + end + + function self:commandsChanged() + local customCommands = widgetHandler.customCommands + + customCommands[#customCommands+1] = { + id = self.cmd, + type = self.cmdType, + hidden = true, + cursor = 'Attack', + } + 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:getPerferedUnit{x = x, z = z} + if not unit then return true end + local unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] + + if self.markerMessage then + Spring.MarkerAddPoint (x, y, z , self.markerMessage, false) + end + + Spring.GiveOrderToUnit(unit, CMD.INSERT, {0, 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 else 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 + + function self:drawWorld() + local _, activeCmd, _ = Spring.GetActiveCommand() + if activeCmd ~= self.cmd then return end + + local mx, my, mz = getMouseTargetPosition() + if not mx or not mz then return end + local unit = self:getPerferedUnit{x = mx, z = mz} + if not unit then return end + local ux, uy, uz = Spring.GetUnitPosition(unit) + local dist = distance(mx, mz, ux, uz) + + local weaponDefId = self.launchableTypes[Spring.GetUnitDefID(unit)].weaponId + local weaponDef = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit)].weapons[weaponDefId].weaponDef] + + local range = weaponDef.range + if dist > range * range then return end + + drawBlastRadius(mx, my, mz, weaponDef) + drawLine(ux, uy, uz, mx, my, mz) + end + + function self:update() + self.bottomLabel:SetCaption(self:getCount()) + end + + return self +end + +local function EOS_controller_class() + local self = missle_class() + self.x = 438 + self.y = 38 + self.name = "tacnuke" + self.cmd = 39610 + self.markerMessage = "Launching EOS" + + self.launchableTypes = { + [UnitDefNames["tacnuke"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if Spring.GetUnitIsDead(unit) then return 0 end + + local x1, y1, z1 = Spring.GetUnitPosition(silo) + local x2, y2, z2 = Spring.GetUnitPosition(unit) + + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end + + return 1 + end + }, + [UnitDefNames["subtacmissile"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + return Spring.GetUnitStockpile(unit) + end + } + } + + return self +end + +local function seismic_controller_class() + local self = missle_class() + self.x = 482 + self.y = 38 + self.name = "seismic" + self.cmd = 39611 + + self.launchableTypes = { + [UnitDefNames["seismic"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if Spring.GetUnitIsDead(unit) then return 0 end + + local x1, y1, z1 = Spring.GetUnitPosition(silo) + local x2, y2, z2 = Spring.GetUnitPosition(unit) + + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end + + return 1 + end + }, + } + + return self +end + +local function shockley_controller_class() + local self = missle_class() + self.x = 526 + self.y = 38 + self.name = "empmissile" + self.cmd = 39612 + self.markerMessage = "Launching Shockley" + + self.launchableTypes = { + [UnitDefNames["empmissile"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if Spring.GetUnitIsDead(unit) then return 0 end + + local x1, y1, z1 = Spring.GetUnitPosition(silo) + local x2, y2, z2 = Spring.GetUnitPosition(unit) + + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end + + return 1 + end + }, + } + + return self +end + +local function inferno_controller_class() + local self = missle_class() + self.x = 570 + self.y = 38 + self.name = "napalmmissile" + self.cmd = 39613 + self.markerMessage = "Launching Inferno" + + self.launchableTypes = { + [UnitDefNames["napalmmissile"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if Spring.GetUnitIsDead(unit) then return 0 end + + local x1, y1, z1 = Spring.GetUnitPosition(silo) + local x2, y2, z2 = Spring.GetUnitPosition(unit) + + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end + + return 1 + end + }, + } + + return self +end + +local function reef_missile_controller_class() + local self = missle_class() + self.x = 394 + self.y = 38 + self.name = "shipcarrier" + self.cmd = 39614 + self.cmdType = CMDTYPE.ICON_UNIT_OR_MAP + self.markerMessage = "Launching reef missile" + + self.launchableTypes = { + [UnitDefNames["shipcarrier"].id] = { + launchCmd = CMD.MANUALFIRE, + weaponId = 2, + getStockpile = function(unit) + return Spring.GetUnitStockpile(unit) + end + }, + } + + return self +end + +local function trinity_missile_controller_class() + local self = missle_class() + self.x = 350 + self.y = 38 + self.name = "staticnuke" + self.cmd = 39615 + self.markerMessage = "Launching trinity missile" + + self.launchableTypes = { + [UnitDefNames["staticnuke"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + return Spring.GetUnitStockpile(unit) + end + }, + } + + return self +end + +local commands = { + EOS = EOS_controller_class(), + seismic = seismic_controller_class(), + shockley = shockley_controller_class(), + inferno = inferno_controller_class(), + reefMissile = reef_missile_controller_class(), + trinityMissile = trinity_missile_controller_class(), +} + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:CommandsChanged() + for _, command in pairs(commands) do + command:commandsChanged() + end +end + +function widget:CommandNotify(cmdID, cmdParams, cmdOptions) + for _, command in pairs(commands) do + if command:commandNotify(cmdID, cmdParams, cmdOptions) then return true end + end +end + +function widget:Initialize() + for _, command in pairs(commands) do + command:initialize() + end +end + +function widget:DrawWorld() + for _, command in pairs(commands) do + command:drawWorld() + end +end + +local UPDATE_FREQUENCY = 0.25 +local timer = UPDATE_FREQUENCY + 1 +function widget:Update(dt) + timer = timer + dt + if timer < UPDATE_FREQUENCY then + return + end + timer = 0 + + for _, command in pairs(commands) do + command:update() + end +end From 91c1f7b9aa25226ea02366026f8934050bdccde3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:35:59 +0000 Subject: [PATCH 02/31] Remove team messaging feature from missile widget The marker message functionality that notified team members of missile launches is now handled by a separate widget, so remove it from the command center. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 9 --------- 1 file changed, 9 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 8ba9d4413b..6ba829c91b 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -258,10 +258,6 @@ local function missle_class() if not unit then return true end local unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] - if self.markerMessage then - Spring.MarkerAddPoint (x, y, z , self.markerMessage, false) - end - Spring.GiveOrderToUnit(unit, CMD.INSERT, {0, unitType.launchCmd, CMD.OPT_SHIFT, unpack(cmdParams)}, CMD.OPT_ALT) return true end @@ -312,7 +308,6 @@ local function EOS_controller_class() self.y = 38 self.name = "tacnuke" self.cmd = 39610 - self.markerMessage = "Launching EOS" self.launchableTypes = { [UnitDefNames["tacnuke"].id] = { @@ -376,7 +371,6 @@ local function shockley_controller_class() self.y = 38 self.name = "empmissile" self.cmd = 39612 - self.markerMessage = "Launching Shockley" self.launchableTypes = { [UnitDefNames["empmissile"].id] = { @@ -405,7 +399,6 @@ local function inferno_controller_class() self.y = 38 self.name = "napalmmissile" self.cmd = 39613 - self.markerMessage = "Launching Inferno" self.launchableTypes = { [UnitDefNames["napalmmissile"].id] = { @@ -435,7 +428,6 @@ local function reef_missile_controller_class() self.name = "shipcarrier" self.cmd = 39614 self.cmdType = CMDTYPE.ICON_UNIT_OR_MAP - self.markerMessage = "Launching reef missile" self.launchableTypes = { [UnitDefNames["shipcarrier"].id] = { @@ -456,7 +448,6 @@ local function trinity_missile_controller_class() self.y = 38 self.name = "staticnuke" self.cmd = 39615 - self.markerMessage = "Launching trinity missile" self.launchableTypes = { [UnitDefNames["staticnuke"].id] = { From e7ecd5ed7573c8e13e423ce92d2b65171ff68156 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:37:15 +0000 Subject: [PATCH 03/31] Add slow missile (Zeno) to command center widget Add slow_missile_controller_class to support launching the Zeno slow missile from the missile command center UI. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 6ba829c91b..e45e0e87e5 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -421,6 +421,34 @@ local function inferno_controller_class() return self end +local function slow_missile_controller_class() + local self = missle_class() + self.x = 614 + self.y = 38 + self.name = "missileslow" + self.cmd = 39616 + + self.launchableTypes = { + [UnitDefNames["missileslow"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = function(unit) + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if Spring.GetUnitIsDead(unit) then return 0 end + + local x1, y1, z1 = Spring.GetUnitPosition(silo) + local x2, y2, z2 = Spring.GetUnitPosition(unit) + + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end + + return 1 + end + }, + } + + return self +end + local function reef_missile_controller_class() local self = missle_class() self.x = 394 @@ -467,6 +495,7 @@ local commands = { seismic = seismic_controller_class(), shockley = shockley_controller_class(), inferno = inferno_controller_class(), + slowMissile = slow_missile_controller_class(), reefMissile = reef_missile_controller_class(), trinityMissile = trinity_missile_controller_class(), } From 31f64c1c29a4a2a5842000ad00b1aed5f8f269e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:41:23 +0000 Subject: [PATCH 04/31] Add Missiles tab to command menu above Orders tab Configure the integral menu to display missile commands (EOS, Seismic, Shockley, Inferno, Reef, Trinity, and Zeno) in a dedicated Missiles tab above the Orders tab. Add display configurations for each missile command with icons and tooltips. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 6d84c42693..5177920c79 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -476,7 +476,26 @@ local factoryButtonLayoutOverride = { } } +-- Missile command display configurations +commandDisplayConfig[39610] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch EOS: Tactical nuclear missile."} +commandDisplayConfig[39611] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Seismic: Area denial seismic missile."} +commandDisplayConfig[39612] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Shockley: EMP missile."} +commandDisplayConfig[39613] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Inferno: Napalm missile."} +commandDisplayConfig[39614] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Reef Missile: Naval missile."} +commandDisplayConfig[39615] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Trinity: Long-range nuclear missile."} +commandDisplayConfig[39616] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Zeno: Slow homing missile."} + local commandPanels = { + { + humanName = "Missiles", + name = "missiles", + inclusionFunction = function(cmdID) + return (cmdID == 39610 or cmdID == 39611 or cmdID == 39612 or + cmdID == 39613 or cmdID == 39614 or cmdID == 39615 or cmdID == 39616) + end, + loiterable = true, + buttonLayoutConfig = buttonLayoutConfig.command, + }, { humanName = "Orders", name = "orders", @@ -484,7 +503,9 @@ 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 (cmdID == 39610 or cmdID == 39611 or cmdID == 39612 or + cmdID == 39613 or cmdID == 39614 or cmdID == 39615 or cmdID == 39616)) end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, From b84a1806409ac4d4ccae6aec3933522d2e2b3a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:43:59 +0000 Subject: [PATCH 05/31] Remove floating window UI from missile widget Remove floatingCommand dependency and associated UI code (initialize, update, assign functions) since missiles are now managed through the command menu tab. Keep core functionality (command registration, blast radius drawing). Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 61 ------------------------- 1 file changed, 61 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index e45e0e87e5..2c730d6da6 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -17,7 +17,6 @@ end -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -VFS.Include(LUAUI_DIRNAME.."Widgets/lib/floatingCommand.lua") VFS.Include(LUAUI_DIRNAME.."Widgets/Utilities/engine_blast_radius.lua") -------------------------------------------------------------------------------- @@ -43,11 +42,6 @@ local function getMouseTargetPosition() end end -local function assign(table, field, value) - table[field] = value - return value -end - local function distance3(x1, y1, z1, x2, y2, z2) return (x1-x2)*(x1-x2)+(y1-y2)*(y1-y2)+(z1-z2)*(z1-z2) end @@ -181,40 +175,6 @@ local function missle_class() return unit1 end - function self:initialize() - floatingCommand{ - name = "launch " .. self.name, - x = self.x, - y = self.y, - action = function() - self:action() - end, - contents = { - assign(self, "bottomLabel", - WG.Chili.Label:New { - x = 0, - y = 0, - right = 5, - bottom = 5, - align = "right", - valign = "bottom", - caption = caption, - fontSize = 16, - autosize = false, - fontShadow = true, - } - ), - WG.Chili.Image:New { - x = "5%", - y = "5%", - right = "5%", - bottom = "5%", - file = "#" .. UnitDefNames[self.name].id, - keepAspect = false, - }, - }, - } - end function self:getPerferedUnit(params) local units = self:getOrderableUnits() @@ -295,9 +255,6 @@ local function missle_class() drawLine(ux, uy, uz, mx, my, mz) end - function self:update() - self.bottomLabel:SetCaption(self:getCount()) - end return self end @@ -515,11 +472,6 @@ function widget:CommandNotify(cmdID, cmdParams, cmdOptions) end end -function widget:Initialize() - for _, command in pairs(commands) do - command:initialize() - end -end function widget:DrawWorld() for _, command in pairs(commands) do @@ -527,16 +479,3 @@ function widget:DrawWorld() end end -local UPDATE_FREQUENCY = 0.25 -local timer = UPDATE_FREQUENCY + 1 -function widget:Update(dt) - timer = timer + dt - if timer < UPDATE_FREQUENCY then - return - end - timer = 0 - - for _, command in pairs(commands) do - command:update() - end -end From 4e7be0939ed37d0b08c4ab3dd7166f6981222a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:48:55 +0000 Subject: [PATCH 06/31] Add safety checks to prevent crashes after missile launch Add null checks throughout the widget to handle invalid units gracefully: - Check for valid positions before accessing unit data - Verify unit definitions and weapon definitions exist - Handle dead units and parent silos safely - Return early on nil values to prevent cascading failures Fixes crash that occurs after missile launches. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 85 +++++++++++++++++++------ 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 2c730d6da6..09f33a8d4d 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -72,18 +72,23 @@ local function missle_class() end function self:getNumberOfQueueLaunches(unit) - local unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] - local numStockpiled = unitType.getStockpile(unit) + local unitDefID = Spring.GetUnitDefID(unit) + if not unitDefID then return 0 end - local numQueued = 0 - if (numStockpiled or 0) ~= 0 then - local cmdQueue = Spring.GetUnitCommands(unit, numStockpiled); + local unitType = self.launchableTypes[unitDefID] + if not unitType then return 0 end - for _, cmd in ipairs(cmdQueue) do - if cmd.id == unitType.launchCmd then numQueued = numQueued + 1 end - end + local numStockpiled = unitType.getStockpile(unit) + if not numStockpiled or numStockpiled == 0 then return 0 end + + local cmdQueue = Spring.GetUnitCommands(unit, numStockpiled) + 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 @@ -91,11 +96,15 @@ local function missle_class() local count = 0 for _, unit in ipairs(self:getOrderableUnits()) do if not Spring.GetUnitIsDead(unit) then - local type = self.launchableTypes[Spring.GetUnitDefID(unit)] - if type then - count = count - + type.getStockpile(unit) - - self:getNumberOfQueueLaunches(unit) + 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 @@ -242,12 +251,26 @@ local function missle_class() if not mx or not mz then return end local unit = self:getPerferedUnit{x = mx, z = mz} if not unit then return end + local ux, uy, uz = Spring.GetUnitPosition(unit) - local dist = distance(mx, mz, ux, uz) + if not ux then return end - local weaponDefId = self.launchableTypes[Spring.GetUnitDefID(unit)].weaponId - local weaponDef = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit)].weapons[weaponDefId].weaponDef] + 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 + + local weapon = unitDef.weapons[unitType.weaponId] + if not weapon then return end + + local weaponDef = WeaponDefs[weapon.weaponDef] + if not weaponDef then return end + + local dist = distance(mx, mz, ux, uz) local range = weaponDef.range if dist > range * range then return end @@ -271,12 +294,16 @@ local function EOS_controller_class() launchCmd = CMD.ATTACK, weaponId = 1, getStockpile = function(unit) - local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") 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, y1, z1 = Spring.GetUnitPosition(silo) local x2, y2, z2 = Spring.GetUnitPosition(unit) + if not x1 or not x2 then return 0 end + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end return 1 @@ -306,12 +333,16 @@ local function seismic_controller_class() launchCmd = CMD.ATTACK, weaponId = 1, getStockpile = function(unit) - local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") 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, y1, z1 = Spring.GetUnitPosition(silo) local x2, y2, z2 = Spring.GetUnitPosition(unit) + if not x1 or not x2 then return 0 end + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end return 1 @@ -334,12 +365,16 @@ local function shockley_controller_class() launchCmd = CMD.ATTACK, weaponId = 1, getStockpile = function(unit) - local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") 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, y1, z1 = Spring.GetUnitPosition(silo) local x2, y2, z2 = Spring.GetUnitPosition(unit) + if not x1 or not x2 then return 0 end + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end return 1 @@ -362,12 +397,16 @@ local function inferno_controller_class() launchCmd = CMD.ATTACK, weaponId = 1, getStockpile = function(unit) - local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") 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, y1, z1 = Spring.GetUnitPosition(silo) local x2, y2, z2 = Spring.GetUnitPosition(unit) + if not x1 or not x2 then return 0 end + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end return 1 @@ -390,12 +429,16 @@ local function slow_missile_controller_class() launchCmd = CMD.ATTACK, weaponId = 1, getStockpile = function(unit) - local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") 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, y1, z1 = Spring.GetUnitPosition(silo) local x2, y2, z2 = Spring.GetUnitPosition(unit) + if not x1 or not x2 then return 0 end + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end return 1 From dd3fe28d9637e7e5daede2b080d6ca3c1a767614 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:57:07 +0000 Subject: [PATCH 07/31] Hide Missiles tab until missile units are built Add hasMissileUnits() function to check if any missile launcher/silo units exist. Update Missiles tab inclusionFunction to hide the tab until missile units are built. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 5177920c79..c43b966413 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -485,11 +485,37 @@ commandDisplayConfig[39614] = { texture = imageDir .. 'Bold/attack.png', tooltip commandDisplayConfig[39615] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Trinity: Long-range nuclear missile."} commandDisplayConfig[39616] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Zeno: Slow homing missile."} +local function hasMissileUnits() + local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + local missileUnitNames = { + ["tacnuke"] = true, + ["subtacmissile"] = true, + ["seismic"] = true, + ["empmissile"] = true, + ["napalmmissile"] = true, + ["missileslow"] = true, + ["shipcarrier"] = true, + ["staticnuke"] = true, + ["staticmissilesilo"] = true, + } + 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 = "Missiles", name = "missiles", inclusionFunction = function(cmdID) + if not hasMissileUnits() then return false end return (cmdID == 39610 or cmdID == 39611 or cmdID == 39612 or cmdID == 39613 or cmdID == 39614 or cmdID == 39615 or cmdID == 39616) end, From 6d7a261903f963c9494074ec3ea7e1adad838018 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:05:51 +0000 Subject: [PATCH 08/31] Add missile count display to command buttons Update widget:Update to periodically refresh the missile count for each command and display it on the button via the command name. Counts update every 0.25 seconds. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 09f33a8d4d..597a6aa82c 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -500,6 +500,9 @@ local commands = { trinityMissile = trinity_missile_controller_class(), } +local UPDATE_FREQUENCY = 0.25 +local timer = UPDATE_FREQUENCY + 1 + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -509,6 +512,30 @@ function widget:CommandsChanged() end end +function widget:Update(dt) + timer = timer + dt + if timer < UPDATE_FREQUENCY then + return + end + timer = 0 + + for _, command in pairs(commands) do + local count = command:getCount() + local customCommands = widgetHandler.customCommands + + for i = 1, #customCommands do + if customCommands[i].id == command.cmd then + if count > 0 then + customCommands[i].name = "x" .. count + else + customCommands[i].name = "" + end + break + end + end + end +end + function widget:CommandNotify(cmdID, cmdParams, cmdOptions) for _, command in pairs(commands) do if command:commandNotify(cmdID, cmdParams, cmdOptions) then return true end From 3d83d6b52829fdb433dc0eb0b308972997d7356a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:11:55 +0000 Subject: [PATCH 09/31] Add missile build progress display to command buttons Add getMaxBuildProgress() to track the highest build progress among all building missiles. Display format: 'x[count] ([percent]%)' or just '[percent]%' if no missiles are ready. Shows progress of the next missile being built. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 33 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 597a6aa82c..7bb0f884b8 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -111,6 +111,24 @@ local function missle_class() 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 + local _, _, _, _, buildProgress = Spring.GetUnitHealth(unitID) + if buildProgress and buildProgress < 1 then + maxProgress = math.max(maxProgress, buildProgress) + end + end + end + end + return maxProgress + end + function self:canGiveOrder(unit) local _, _, _, _, build = Spring.GetUnitHealth(unit) local type = self.launchableTypes[Spring.GetUnitDefID(unit)] @@ -521,15 +539,24 @@ function widget:Update(dt) for _, command in pairs(commands) do local count = command:getCount() + local buildProgress = command:getMaxBuildProgress() local customCommands = widgetHandler.customCommands for i = 1, #customCommands do if customCommands[i].id == command.cmd then + local displayName = "" if count > 0 then - customCommands[i].name = "x" .. count - else - customCommands[i].name = "" + displayName = "x" .. count + end + if buildProgress > 0 then + local progressPercent = math.floor(buildProgress * 100) + if displayName ~= "" then + displayName = displayName .. " (" .. progressPercent .. "%)" + else + displayName = progressPercent .. "%" + end end + customCommands[i].name = displayName break end end From d5f3a7d6e453727f620636ff3904589d710e4580 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:14:07 +0000 Subject: [PATCH 10/31] Add visual progress bar support for missile commands Create helper widget (gui_missile_progress.lua) to display visual progress bars on missile command buttons, showing build progress of next missile like factory units. Export progress data via WG.missileProgress for access by other widgets. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/gui_missile_progress.lua | 83 +++++++++++++++++++++++++ LuaUI/Widgets/missle_command_center.lua | 5 ++ 2 files changed, 88 insertions(+) create mode 100644 LuaUI/Widgets/gui_missile_progress.lua diff --git a/LuaUI/Widgets/gui_missile_progress.lua b/LuaUI/Widgets/gui_missile_progress.lua new file mode 100644 index 0000000000..f04cdf58f5 --- /dev/null +++ b/LuaUI/Widgets/gui_missile_progress.lua @@ -0,0 +1,83 @@ +function widget:GetInfo() + return { + name = "Missile Command Progress", + desc = "Displays visual progress bars for missile building", + author = "Amnykon", + date = "2026", + license = "GNU GPL, v2 or later", + layer = 5, + enabled = true, + handler = true, + } +end + +local missileCommands = { + [39610] = true, -- EOS + [39611] = true, -- Seismic + [39612] = true, -- Shockley + [39613] = true, -- Inferno + [39614] = true, -- Reef + [39615] = true, -- Trinity + [39616] = true, -- Slow +} + +local UPDATE_FREQUENCY = 0.25 +local timer = UPDATE_FREQUENCY + 1 + +local function getMissileProgress(cmdID) + local progressMap = { + [39610] = {"tacnuke", "subtacmissile"}, + [39611] = {"seismic"}, + [39612] = {"empmissile"}, + [39613] = {"napalmmissile"}, + [39614] = {"shipcarrier"}, + [39615] = {"staticnuke"}, + [39616] = {"missileslow"}, + } + + local unitNames = progressMap[cmdID] + if not unitNames then return 0 end + + local maxProgress = 0 + local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + + for _, unitID in ipairs(teamUnits) do + if not Spring.GetUnitIsDead(unitID) then + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID then + local unitDef = UnitDefs[unitDefID] + if unitDef then + for _, unitName in ipairs(unitNames) do + if unitDef.name == unitName then + local _, _, _, _, buildProgress = Spring.GetUnitHealth(unitID) + if buildProgress and buildProgress < 1 then + maxProgress = math.max(maxProgress, buildProgress) + end + break + end + end + end + end + end + end + + return maxProgress +end + +function widget:Update(dt) + timer = timer + dt + if timer < UPDATE_FREQUENCY then + return + end + timer = 0 + + local integralMenu = widgetHandler:FindWidget("Chili Integral Menu") + if not integralMenu then return end + + for cmdID in pairs(missileCommands) do + local progress = getMissileProgress(cmdID) + if progress > 0 and integralMenu.SetCmdButtonProgress then + integralMenu:SetCmdButtonProgress(cmdID, progress) + end + end +end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 7bb0f884b8..a7666d8c96 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -537,11 +537,16 @@ function widget:Update(dt) end timer = 0 + WG.missileProgress = WG.missileProgress or {} + for _, command in pairs(commands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() local customCommands = widgetHandler.customCommands + -- Export progress data for other widgets + WG.missileProgress[command.cmd] = buildProgress + for i = 1, #customCommands do if customCommands[i].id == command.cmd then local displayName = "" From 5606e56a5a7e33b3579c1a307f556b79499e5fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:15:57 +0000 Subject: [PATCH 11/31] Consolidate progress bar logic into missile widget Remove unnecessary separate helper widget. Handle progress bar updates directly in the missile widget's Update function. Simplifies codebase without losing functionality. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/gui_missile_progress.lua | 83 ------------------------- LuaUI/Widgets/missle_command_center.lua | 10 +-- 2 files changed, 6 insertions(+), 87 deletions(-) delete mode 100644 LuaUI/Widgets/gui_missile_progress.lua diff --git a/LuaUI/Widgets/gui_missile_progress.lua b/LuaUI/Widgets/gui_missile_progress.lua deleted file mode 100644 index f04cdf58f5..0000000000 --- a/LuaUI/Widgets/gui_missile_progress.lua +++ /dev/null @@ -1,83 +0,0 @@ -function widget:GetInfo() - return { - name = "Missile Command Progress", - desc = "Displays visual progress bars for missile building", - author = "Amnykon", - date = "2026", - license = "GNU GPL, v2 or later", - layer = 5, - enabled = true, - handler = true, - } -end - -local missileCommands = { - [39610] = true, -- EOS - [39611] = true, -- Seismic - [39612] = true, -- Shockley - [39613] = true, -- Inferno - [39614] = true, -- Reef - [39615] = true, -- Trinity - [39616] = true, -- Slow -} - -local UPDATE_FREQUENCY = 0.25 -local timer = UPDATE_FREQUENCY + 1 - -local function getMissileProgress(cmdID) - local progressMap = { - [39610] = {"tacnuke", "subtacmissile"}, - [39611] = {"seismic"}, - [39612] = {"empmissile"}, - [39613] = {"napalmmissile"}, - [39614] = {"shipcarrier"}, - [39615] = {"staticnuke"}, - [39616] = {"missileslow"}, - } - - local unitNames = progressMap[cmdID] - if not unitNames then return 0 end - - local maxProgress = 0 - local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} - - for _, unitID in ipairs(teamUnits) do - if not Spring.GetUnitIsDead(unitID) then - local unitDefID = Spring.GetUnitDefID(unitID) - if unitDefID then - local unitDef = UnitDefs[unitDefID] - if unitDef then - for _, unitName in ipairs(unitNames) do - if unitDef.name == unitName then - local _, _, _, _, buildProgress = Spring.GetUnitHealth(unitID) - if buildProgress and buildProgress < 1 then - maxProgress = math.max(maxProgress, buildProgress) - end - break - end - end - end - end - end - end - - return maxProgress -end - -function widget:Update(dt) - timer = timer + dt - if timer < UPDATE_FREQUENCY then - return - end - timer = 0 - - local integralMenu = widgetHandler:FindWidget("Chili Integral Menu") - if not integralMenu then return end - - for cmdID in pairs(missileCommands) do - local progress = getMissileProgress(cmdID) - if progress > 0 and integralMenu.SetCmdButtonProgress then - integralMenu:SetCmdButtonProgress(cmdID, progress) - end - end -end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index a7666d8c96..ed78fd8345 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -537,16 +537,13 @@ function widget:Update(dt) end timer = 0 - WG.missileProgress = WG.missileProgress or {} + local integralMenu = widgetHandler:FindWidget("Chili Integral Menu") for _, command in pairs(commands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() local customCommands = widgetHandler.customCommands - -- Export progress data for other widgets - WG.missileProgress[command.cmd] = buildProgress - for i = 1, #customCommands do if customCommands[i].id == command.cmd then local displayName = "" @@ -562,6 +559,11 @@ function widget:Update(dt) end end customCommands[i].name = displayName + + -- Update visual progress bar on integral menu button if available + if integralMenu and integralMenu.SetCmdButtonProgress and buildProgress > 0 then + integralMenu:SetCmdButtonProgress(command.cmd, buildProgress) + end break end end From dd43a39a2d23377cfdd15f8fdb3b6eb66d137281 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:17:07 +0000 Subject: [PATCH 12/31] Add proper visual progress bar support via helper widget Create helper widget that searches for missile command buttons and updates their progress bars using SetProgressBar() method, matching factory unit behavior. Missile widget exports progress data via WG.missileProgress for the helper to consume. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/gui_missile_progress.lua | 70 +++++++++++++++++++++++++ LuaUI/Widgets/missle_command_center.lua | 10 ++-- 2 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 LuaUI/Widgets/gui_missile_progress.lua diff --git a/LuaUI/Widgets/gui_missile_progress.lua b/LuaUI/Widgets/gui_missile_progress.lua new file mode 100644 index 0000000000..3515fdbae3 --- /dev/null +++ b/LuaUI/Widgets/gui_missile_progress.lua @@ -0,0 +1,70 @@ +function widget:GetInfo() + return { + name = "Missile Command Progress", + desc = "Displays visual progress bars for missile building", + author = "Amnykon", + date = "2026", + license = "GNU GPL, v2 or later", + layer = 5, + enabled = true, + handler = true, + } +end + +local missileCommands = { + [39610] = true, + [39611] = true, + [39612] = true, + [39613] = true, + [39614] = true, + [39615] = true, + [39616] = true, +} + +local UPDATE_FREQUENCY = 0.25 +local timer = UPDATE_FREQUENCY + 1 +local buttonCache = {} + +local function findButtonsByCommand() + local screen = WG.Chili.Screen0 + if not screen then return end + + local function searchChildren(control) + if not control then return end + + if control.cmdID and missileCommands[control.cmdID] then + buttonCache[control.cmdID] = control + end + + if control.children then + for _, child in ipairs(control.children) do + searchChildren(child) + end + end + end + + if screen.children then + for _, child in ipairs(screen.children) do + searchChildren(child) + end + end +end + +function widget:Update(dt) + timer = timer + dt + if timer < UPDATE_FREQUENCY then + return + end + timer = 0 + + findButtonsByCommand() + + for cmdID in pairs(missileCommands) do + local progress = WG.missileProgress and WG.missileProgress[cmdID] or 0 + local button = buttonCache[cmdID] + + if button and button.SetProgressBar then + button:SetProgressBar(progress) + end + end +end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index ed78fd8345..5a484125f2 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -537,13 +537,16 @@ function widget:Update(dt) end timer = 0 - local integralMenu = widgetHandler:FindWidget("Chili Integral Menu") + WG.missileProgress = WG.missileProgress or {} for _, command in pairs(commands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() local customCommands = widgetHandler.customCommands + -- Export progress for other widgets + WG.missileProgress[command.cmd] = buildProgress + for i = 1, #customCommands do if customCommands[i].id == command.cmd then local displayName = "" @@ -559,11 +562,6 @@ function widget:Update(dt) end end customCommands[i].name = displayName - - -- Update visual progress bar on integral menu button if available - if integralMenu and integralMenu.SetCmdButtonProgress and buildProgress > 0 then - integralMenu:SetCmdButtonProgress(command.cmd, buildProgress) - end break end end From 608fac6eb195b8817e09d8228523198ddc51d442 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:19:48 +0000 Subject: [PATCH 13/31] Merge progress bar logic into missile widget Consolidate all missile command functionality into missle_command_center.lua. The widget now handles: command registration, count display, progress display (text and visual progress bars), and button updates. Removes unnecessary separation. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/gui_missile_progress.lua | 70 ------------------------- LuaUI/Widgets/missle_command_center.lua | 47 +++++++++++++++-- 2 files changed, 43 insertions(+), 74 deletions(-) delete mode 100644 LuaUI/Widgets/gui_missile_progress.lua diff --git a/LuaUI/Widgets/gui_missile_progress.lua b/LuaUI/Widgets/gui_missile_progress.lua deleted file mode 100644 index 3515fdbae3..0000000000 --- a/LuaUI/Widgets/gui_missile_progress.lua +++ /dev/null @@ -1,70 +0,0 @@ -function widget:GetInfo() - return { - name = "Missile Command Progress", - desc = "Displays visual progress bars for missile building", - author = "Amnykon", - date = "2026", - license = "GNU GPL, v2 or later", - layer = 5, - enabled = true, - handler = true, - } -end - -local missileCommands = { - [39610] = true, - [39611] = true, - [39612] = true, - [39613] = true, - [39614] = true, - [39615] = true, - [39616] = true, -} - -local UPDATE_FREQUENCY = 0.25 -local timer = UPDATE_FREQUENCY + 1 -local buttonCache = {} - -local function findButtonsByCommand() - local screen = WG.Chili.Screen0 - if not screen then return end - - local function searchChildren(control) - if not control then return end - - if control.cmdID and missileCommands[control.cmdID] then - buttonCache[control.cmdID] = control - end - - if control.children then - for _, child in ipairs(control.children) do - searchChildren(child) - end - end - end - - if screen.children then - for _, child in ipairs(screen.children) do - searchChildren(child) - end - end -end - -function widget:Update(dt) - timer = timer + dt - if timer < UPDATE_FREQUENCY then - return - end - timer = 0 - - findButtonsByCommand() - - for cmdID in pairs(missileCommands) do - local progress = WG.missileProgress and WG.missileProgress[cmdID] or 0 - local button = buttonCache[cmdID] - - if button and button.SetProgressBar then - button:SetProgressBar(progress) - end - end -end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 5a484125f2..3dfe220457 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -520,6 +520,42 @@ local commands = { local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 +local buttonCache = {} + +local missileCommandIDs = { + [39610] = true, + [39611] = true, + [39612] = true, + [39613] = true, + [39614] = true, + [39615] = true, + [39616] = true, +} + +local function findButtonsByCommand() + local screen = WG.Chili.Screen0 + if not screen then return end + + local function searchChildren(control) + if not control then return end + + if control.cmdID and missileCommandIDs[control.cmdID] then + buttonCache[control.cmdID] = control + end + + if control.children then + for _, child in ipairs(control.children) do + searchChildren(child) + end + end + end + + if screen.children then + for _, child in ipairs(screen.children) do + searchChildren(child) + end + end +end -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -537,16 +573,13 @@ function widget:Update(dt) end timer = 0 - WG.missileProgress = WG.missileProgress or {} + findButtonsByCommand() for _, command in pairs(commands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() local customCommands = widgetHandler.customCommands - -- Export progress for other widgets - WG.missileProgress[command.cmd] = buildProgress - for i = 1, #customCommands do if customCommands[i].id == command.cmd then local displayName = "" @@ -562,6 +595,12 @@ function widget:Update(dt) end end customCommands[i].name = displayName + + -- Update visual progress bar on button + local button = buttonCache[command.cmd] + if button and button.SetProgressBar then + button:SetProgressBar(buildProgress) + end break end end From 3437dabb0048c4ba770b668e297bd3651f2e24f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:28:44 +0000 Subject: [PATCH 14/31] Add tab badge support with missile unit icon and count Extend integral menu to support tab badges showing unit icon and missile count. Add badgeUnitName and badgeCountWG configuration fields to command panels. Missile widget exports total count via WG.missileTotalCount. Badge displays on Missiles tab showing tacnuke icon with ready missile count. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 2 + LuaUI/Widgets/gui_chili_integral_menu.lua | 67 ++++++++++++++++++++--- LuaUI/Widgets/missle_command_center.lua | 7 +++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index c43b966413..9dc0bcfb49 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -521,6 +521,8 @@ local commandPanels = { end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, + badgeUnitName = "tacnuke", + badgeCountWG = "missileTotalCount", }, { humanName = "Orders", diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index a3f58bb81d..edd5ec5e2d 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -1819,9 +1819,9 @@ end -------------------------------------------------------------------------------- -- Tab Panel -local function GetTabButton(panel, contentControl, name, humanName, hotkey, loiterable, OnSelect) +local function GetTabButton(panel, contentControl, name, humanName, hotkey, loiterable, OnSelect, badgeConfig) local disabled = disabledTabs[name] - + local function DoClick(mouse) if disabled or TabClickFunction(mouse) then return @@ -1832,7 +1832,7 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit OnSelect() end end - + local button = Button:New { classname = "button_tab", caption = humanName, @@ -1846,23 +1846,65 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit }, } button.backgroundColor[4] = 0.4 - + if disabled then button.font = WG.GetSpecialFont(14, "integral_grey", {outlineColor = {0, 0, 0, 1}, color = {0.6, 0.6, 0.6, 1}}) button.supressButtonReaction = true end - + local hideHotkey = loiterable - + if hotkey and (not hideHotkey) and (not disabled) then button:SetCaption(humanName .. " (" .. GetGreenStr(hotkey) .. ")") end - + local externalFunctionsAndData = { button = button, name = name, DoClick = DoClick, + badgePanel = nil, } + + -- Create badge if configured + if badgeConfig and badgeConfig.unitName then + local unitDef = UnitDefNames[badgeConfig.unitName] + if unitDef then + externalFunctionsAndData.badgePanel = Panel:New { + x = "100%-30", + y = 0, + width = 30, + height = 30, + parent = button, + } + + Image:New { + x = 0, + y = 0, + width = 20, + height = 20, + file = "#" .. unitDef.id, + parent = externalFunctionsAndData.badgePanel, + } + + externalFunctionsAndData.badgeLabel = Label:New { + x = 20, + y = 0, + width = 10, + height = 20, + caption = "0", + align = "left", + valign = "center", + fontSize = 10, + parent = externalFunctionsAndData.badgePanel, + } + + function externalFunctionsAndData.UpdateBadgeCount(count) + if externalFunctionsAndData.badgeLabel then + externalFunctionsAndData.badgeLabel:SetCaption(tostring(count)) + end + end + end + end function externalFunctionsAndData.IsTabSelected() return contentControl.visible @@ -2345,7 +2387,7 @@ local function InitializeControls() end end - data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect) + data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect, {unitName = data.badgeUnitName, countWG = data.badgeCountWG}) if data.gridHotkeys and ((not data.disableableKeys) or options.unitsHotkeys2.value) then data.buttons.ApplyGridHotkeys(gridMap, (gridCustomOverrides and gridCustomOverrides[data.name]) or {}) @@ -2550,6 +2592,15 @@ function widget:Update() local _,cmdID = spGetActiveCommand() UpdateButtonSelection(cmdID) UpdateReturnToOrders(cmdID) + + -- Update tab badges + for i = 1, #commandPanels do + local panelData = commandPanels[i] + if panelData.badgeCountWG and panelData.tabButton and panelData.tabButton.UpdateBadgeCount then + local count = WG[panelData.badgeCountWG] or 0 + panelData.tabButton:UpdateBadgeCount(count) + end + end end function widget:KeyPress(key, modifier, isRepeat) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 3dfe220457..f5c6b1a2c2 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -575,11 +575,15 @@ function widget:Update(dt) findButtonsByCommand() + local totalMissileCount = 0 + for _, command in pairs(commands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() local customCommands = widgetHandler.customCommands + totalMissileCount = totalMissileCount + count + for i = 1, #customCommands do if customCommands[i].id == command.cmd then local displayName = "" @@ -605,6 +609,9 @@ function widget:Update(dt) end end end + + -- Export total count for tab badge + WG.missileTotalCount = totalMissileCount end function widget:CommandNotify(cmdID, cmdParams, cmdOptions) From da09486ed61b76afd2d6c22c0a5d23adc3e329e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 02:06:15 +0000 Subject: [PATCH 15/31] Implement all 5 widget improvements 1. Dynamic tab visibility: Missiles tab now hides when no missiles are available 2. Weapon tooltips: Added detailed descriptions for each missile type 3. Keybindings: Added grid hotkeys support for quick missile selection 4. Disable buttons when count is 0: Buttons grey out when no missiles available 5. Per-type counts: EOS shares tacnuke and subtacmissile counts (already working) Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 16 +++++++++------- LuaUI/Widgets/gui_chili_integral_menu.lua | 17 ++++++++++++++++- LuaUI/Widgets/missle_command_center.lua | 3 +++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 9dc0bcfb49..d5675aa1ec 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -477,13 +477,13 @@ local factoryButtonLayoutOverride = { } -- Missile command display configurations -commandDisplayConfig[39610] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch EOS: Tactical nuclear missile."} -commandDisplayConfig[39611] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Seismic: Area denial seismic missile."} -commandDisplayConfig[39612] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Shockley: EMP missile."} -commandDisplayConfig[39613] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Inferno: Napalm missile."} -commandDisplayConfig[39614] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Reef Missile: Naval missile."} -commandDisplayConfig[39615] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Trinity: Long-range nuclear missile."} -commandDisplayConfig[39616] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Zeno: Slow homing missile."} +commandDisplayConfig[39610] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage."} +commandDisplayConfig[39611] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Seismic\nArea denial seismic missile, slows units."} +commandDisplayConfig[39612] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units."} +commandDisplayConfig[39613] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Inferno (Napalm)\nNapalm missile with persistent damage."} +commandDisplayConfig[39614] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Disarm Missile\nDisables units temporarily."} +commandDisplayConfig[39615] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile."} +commandDisplayConfig[39616] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage."} local function hasMissileUnits() local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} @@ -523,6 +523,8 @@ local commandPanels = { buttonLayoutConfig = buttonLayoutConfig.command, badgeUnitName = "tacnuke", badgeCountWG = "missileTotalCount", + gridHotkeys = true, + returnOnClick = "orders", }, { humanName = "Orders", diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index edd5ec5e2d..ff43e50e35 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -2593,13 +2593,28 @@ function widget:Update() UpdateButtonSelection(cmdID) UpdateReturnToOrders(cmdID) - -- Update tab badges + -- Update tab badges and visibility for i = 1, #commandPanels do local panelData = commandPanels[i] + + -- Update badge count if panelData.badgeCountWG and panelData.tabButton and panelData.tabButton.UpdateBadgeCount then local count = WG[panelData.badgeCountWG] or 0 panelData.tabButton:UpdateBadgeCount(count) end + + -- Update tab visibility for panels with dynamic visibility + if panelData.name == "missiles" and panelData.tabButton then + local hasCommands = false + local customCommands = widgetHandler.customCommands + for j = 1, #customCommands do + if panelData.inclusionFunction(customCommands[j].id) then + hasCommands = true + break + end + end + panelData.tabButton.button:SetVisibility(hasCommands) + end end end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index f5c6b1a2c2..b871a568fc 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -600,6 +600,9 @@ function widget:Update(dt) end customCommands[i].name = displayName + -- Disable button if no missiles available + customCommands[i].disabled = (count == 0) + -- Update visual progress bar on button local button = buttonCache[command.cmd] if button and button.SetProgressBar then From 7173b99c456550c4bda136607bf0d4af3111ccf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 02:25:56 +0000 Subject: [PATCH 16/31] Fix #4: Centralize missile command IDs to prevent duplication Define missileCmdIDs table and isMissileCommand() helper at top of integral_menu_config.lua. Replace hardcoded ID lists in both Missiles panel inclusionFunction and Orders panel exclusion with helper calls. Fixes maintenance issue where IDs could get out of sync between panels. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index d5675aa1ec..0b0c5d0eb7 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) +local missileCmdIDs = { + 39610, -- EOS + 39611, -- Seismic + 39612, -- Shockley + 39613, -- Inferno + 39614, -- Reef Missile + 39615, -- Trinity + 39616, -- Zeno +} + +local function isMissileCommand(cmdID) + for _, id in ipairs(missileCmdIDs) do + if cmdID == id then return true end + end + return false +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Tooltips @@ -516,8 +533,7 @@ local commandPanels = { name = "missiles", inclusionFunction = function(cmdID) if not hasMissileUnits() then return false end - return (cmdID == 39610 or cmdID == 39611 or cmdID == 39612 or - cmdID == 39613 or cmdID == 39614 or cmdID == 39615 or cmdID == 39616) + return isMissileCommand(cmdID) end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, @@ -534,8 +550,7 @@ local commandPanels = { not buildCmdEconomy[cmdID] and not buildCmdFactory[cmdID] and not buildCmdSpecial[cmdID] and not buildCmdDefence[cmdID] and not plateCommandID[cmdID] and - not (cmdID == 39610 or cmdID == 39611 or cmdID == 39612 or - cmdID == 39613 or cmdID == 39614 or cmdID == 39615 or cmdID == 39616)) + not isMissileCommand(cmdID)) end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, From 812e69342fe40e0243437a34f3beeac0b66f2ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 02:55:04 +0000 Subject: [PATCH 17/31] Fix remaining 7 code review issues 1-3. Add null checks for type and weaponDef before accessing in perferedUnit 4. Fix Spring.GetUnitCommands parameter - use fixed 100 instead of variable stockpile 5. Add null check for unit position before using in distance calculation 6. Add null check for tabButton.button before calling SetVisibility 7. Initialize WG.missileTotalCount in widget:Initialize to fix badge when disabled All fixes are defensive nil/validation checks to prevent crashes. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/gui_chili_integral_menu.lua | 2 +- LuaUI/Widgets/missle_command_center.lua | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index ff43e50e35..bb1bade1c2 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -2604,7 +2604,7 @@ function widget:Update() end -- Update tab visibility for panels with dynamic visibility - if panelData.name == "missiles" and panelData.tabButton then + if panelData.name == "missiles" and panelData.tabButton and panelData.tabButton.button then local hasCommands = false local customCommands = widgetHandler.customCommands for j = 1, #customCommands do diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index b871a568fc..acac6a270e 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -14,6 +14,10 @@ function widget:GetInfo() } end +function widget:Initialize() + WG.missileTotalCount = 0 +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -81,7 +85,7 @@ local function missle_class() local numStockpiled = unitType.getStockpile(unit) if not numStockpiled or numStockpiled == 0 then return 0 end - local cmdQueue = Spring.GetUnitCommands(unit, numStockpiled) + local cmdQueue = Spring.GetUnitCommands(unit, 100) if not cmdQueue then return 0 end local numQueued = 0 @@ -142,10 +146,14 @@ local function missle_class() function self:perferedUnit(unit1, unit2, params) local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) - local unit2Dist = distance(params.x, params.z, unit2x, unit2z) + if not unit2x then return unit1 end local type2 = self.launchableTypes[Spring.GetUnitDefID(unit2)] + if not type2 then return unit1 end + + local unit2Dist = distance(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 @@ -154,7 +162,10 @@ local function missle_class() 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 local unit1Silo = Spring.GetUnitRulesParam(unit1, "missile_parentSilo") local unit1Selected = params.selectedUnits[unit1] or (unit1Silo and params.selectedUnits[unit1Silo]) @@ -244,6 +255,7 @@ local function missle_class() local unit = self:getPerferedUnit{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 Spring.GiveOrderToUnit(unit, CMD.INSERT, {0, unitType.launchCmd, CMD.OPT_SHIFT, unpack(cmdParams)}, CMD.OPT_ALT) return true From 908d1bb08de9495d7bbd70ec9a10bdd40c435262 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 03:55:49 +0000 Subject: [PATCH 18/31] =?UTF-8?q?Fix=20widget=20name:=20'missle'=20?= =?UTF-8?q?=E2=86=92=20'missile'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct spelling and update description. Use 'command center: missiles' naming convention for consistency with planned global command tabs. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Widgets/missle_command_center.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index acac6a270e..303c92b183 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -3,8 +3,8 @@ function widget:GetInfo() return { - name = "command center: missle", - desc = "Add missle commands to command center", + name = "command center: missiles", + desc = "Add missile commands to command center", author = "Amnykon", date = "2021-07-30", license = "GNU GPL, v2 or later", From f083735cce8a718604c48944cefa315e18f3cf23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 04:18:36 +0000 Subject: [PATCH 19/31] Improve missile configuration with metadata dictionary Convert missileCmdIDs to dictionary with id, name, and icon fields. Use loop to generate commandDisplayConfig from dictionary instead of hardcoding each ID. Use missile/launcher unit icons instead of generic attack icon. Consolidates missile metadata in one place. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 43 ++++++++++++++++---------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 0b0c5d0eb7..9b613b12fa 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -1,18 +1,18 @@ local buildCmdFactory, buildCmdEconomy, buildCmdDefence, buildCmdSpecial, buildCmdUnits, cmdPosDef, factoryUnitPosDef = include("Configs/integral_menu_commands_processed.lua", nil, VFS.RAW_FIRST) local missileCmdIDs = { - 39610, -- EOS - 39611, -- Seismic - 39612, -- Shockley - 39613, -- Inferno - 39614, -- Reef Missile - 39615, -- Trinity - 39616, -- Zeno + {id = 39610, name = "EOS", icon = "tacnuke"}, + {id = 39611, name = "Seismic", icon = "seismic"}, + {id = 39612, name = "Shockley", icon = "empmissile"}, + {id = 39613, name = "Inferno", icon = "napalmmissile"}, + {id = 39614, name = "Reef Missile", icon = "shipcarrier"}, + {id = 39615, name = "Trinity", icon = "staticnuke"}, + {id = 39616, name = "Zeno", icon = "missileslow"}, } local function isMissileCommand(cmdID) - for _, id in ipairs(missileCmdIDs) do - if cmdID == id then return true end + for _, missile in ipairs(missileCmdIDs) do + if cmdID == missile.id then return true end end return false end @@ -494,13 +494,24 @@ local factoryButtonLayoutOverride = { } -- Missile command display configurations -commandDisplayConfig[39610] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage."} -commandDisplayConfig[39611] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Seismic\nArea denial seismic missile, slows units."} -commandDisplayConfig[39612] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units."} -commandDisplayConfig[39613] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Inferno (Napalm)\nNapalm missile with persistent damage."} -commandDisplayConfig[39614] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Disarm Missile\nDisables units temporarily."} -commandDisplayConfig[39615] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile."} -commandDisplayConfig[39616] = { texture = imageDir .. 'Bold/attack.png', tooltip = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage."} +local missileTooltips = { + [39610] = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage.", + [39611] = "Launch Seismic\nArea denial seismic missile, slows units.", + [39612] = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units.", + [39613] = "Launch Inferno (Napalm)\nNapalm missile with persistent damage.", + [39614] = "Launch Disarm Missile\nDisables units temporarily.", + [39615] = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile.", + [39616] = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage.", +} + +for _, missile in ipairs(missileCmdIDs) do + local unitDef = UnitDefNames[missile.icon] + local icon = unitDef and ("#" .. unitDef.id) or (imageDir .. 'Bold/attack.png') + commandDisplayConfig[missile.id] = { + texture = icon, + tooltip = missileTooltips[missile.id] + } +end local function hasMissileUnits() local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} From 757d44d854e45f61a513e9a54c2b054d37be9162 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 04:26:12 +0000 Subject: [PATCH 20/31] Refactor missile config: rename missileCmdIDs to missileCmds and embed tooltips Move tooltip data from separate missileTooltips dictionary into each missile entry in the missileCmds metadata dictionary. This removes the tooltip upvalue and makes the configuration more self-contained. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01GiaPfKSj8kGFjaUPyxYYHd --- LuaUI/Configs/integral_menu_config.lua | 33 +++++++++----------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 9b613b12fa..f1b8d1d9e1 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -1,17 +1,17 @@ local buildCmdFactory, buildCmdEconomy, buildCmdDefence, buildCmdSpecial, buildCmdUnits, cmdPosDef, factoryUnitPosDef = include("Configs/integral_menu_commands_processed.lua", nil, VFS.RAW_FIRST) -local missileCmdIDs = { - {id = 39610, name = "EOS", icon = "tacnuke"}, - {id = 39611, name = "Seismic", icon = "seismic"}, - {id = 39612, name = "Shockley", icon = "empmissile"}, - {id = 39613, name = "Inferno", icon = "napalmmissile"}, - {id = 39614, name = "Reef Missile", icon = "shipcarrier"}, - {id = 39615, name = "Trinity", icon = "staticnuke"}, - {id = 39616, name = "Zeno", icon = "missileslow"}, +local missileCmds = { + {id = 39610, name = "EOS", icon = "tacnuke", tooltip = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage."}, + {id = 39611, name = "Seismic", icon = "seismic", tooltip = "Launch Seismic\nArea denial seismic missile, slows units."}, + {id = 39612, name = "Shockley", icon = "empmissile", tooltip = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units."}, + {id = 39613, name = "Inferno", icon = "napalmmissile", tooltip = "Launch Inferno (Napalm)\nNapalm missile with persistent damage."}, + {id = 39614, name = "Reef Missile", icon = "shipcarrier", tooltip = "Launch Disarm Missile\nDisables units temporarily."}, + {id = 39615, name = "Trinity", icon = "staticnuke", tooltip = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile."}, + {id = 39616, name = "Zeno", icon = "missileslow", tooltip = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage."}, } local function isMissileCommand(cmdID) - for _, missile in ipairs(missileCmdIDs) do + for _, missile in ipairs(missileCmds) do if cmdID == missile.id then return true end end return false @@ -493,23 +493,12 @@ local factoryButtonLayoutOverride = { } } --- Missile command display configurations -local missileTooltips = { - [39610] = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage.", - [39611] = "Launch Seismic\nArea denial seismic missile, slows units.", - [39612] = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units.", - [39613] = "Launch Inferno (Napalm)\nNapalm missile with persistent damage.", - [39614] = "Launch Disarm Missile\nDisables units temporarily.", - [39615] = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile.", - [39616] = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage.", -} - -for _, missile in ipairs(missileCmdIDs) do +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 = missileTooltips[missile.id] + tooltip = missile.tooltip } end From 5a70f509b77f3a578fded6ccebe41bd8f763f938 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 1 Jul 2026 10:36:47 -0500 Subject: [PATCH 21/31] Fix missile widget: crashes, tab layout, tooltips, count/progress bar - Fix Chili Integral Menu crash: remove SetVisibility call on unparented tab button; tab presence is handled by the commandCount/SetTabs machinery. - Guard nil ud.shieldPower in Chili Selections & CursorTip. - Make the missile widget self-contained: inline the blast-radius draw code (the VFS include file was never committed) and enable it by default. - Show missile commands: drop hidden flag, add action/params so they appear in the new Missiles tab with tooltips. - Order buttons like the missile silo (row 1: Trinity, Reef; row 2: silo missiles in buildoptions order) via per-command col/row positions. - Show count label ("xN") via DRAW_NAME_COMMANDS driven by the config's drawName flag. - Show a factory-style build progress bar via a new WG.IntegralMenu.SetCommandProgress API (added to externalFunctions so it survives the Initialize assignment); source progress from unit build progress or stockpile build percent as appropriate. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Configs/integral_menu_config.lua | 33 +-- LuaUI/Widgets/gui_chili_integral_menu.lua | 35 ++-- .../gui_chili_selections_and_cursortip.lua | 4 +- LuaUI/Widgets/missle_command_center.lua | 188 +++++++++++------- 4 files changed, 162 insertions(+), 98 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index f1b8d1d9e1..7619dd2606 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -1,20 +1,25 @@ local buildCmdFactory, buildCmdEconomy, buildCmdDefence, buildCmdSpecial, buildCmdUnits, cmdPosDef, factoryUnitPosDef = include("Configs/integral_menu_commands_processed.lua", nil, VFS.RAW_FIRST) +-- Row 1: Trinity and Reef (standalone, not silo missiles). +-- Row 2: the missile silo's missiles, in the silo's buildoptions order +-- (tacnuke, seismic, empmissile, napalmmissile, missileslow). local missileCmds = { - {id = 39610, name = "EOS", icon = "tacnuke", tooltip = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage."}, - {id = 39611, name = "Seismic", icon = "seismic", tooltip = "Launch Seismic\nArea denial seismic missile, slows units."}, - {id = 39612, name = "Shockley", icon = "empmissile", tooltip = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units."}, - {id = 39613, name = "Inferno", icon = "napalmmissile", tooltip = "Launch Inferno (Napalm)\nNapalm missile with persistent damage."}, - {id = 39614, name = "Reef Missile", icon = "shipcarrier", tooltip = "Launch Disarm Missile\nDisables units temporarily."}, - {id = 39615, name = "Trinity", icon = "staticnuke", tooltip = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile."}, - {id = 39616, name = "Zeno", icon = "missileslow", tooltip = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage."}, + {id = 39615, name = "Trinity", icon = "staticnuke", col = 1, row = 1, tooltip = "Launch Trinity (Strategic Nuke)\nLong-range nuclear missile."}, + {id = 39614, name = "Reef Missile", icon = "shipcarrier", col = 2, row = 1, tooltip = "Launch Disarm Missile\nDisables units temporarily."}, + {id = 39610, name = "EOS", icon = "tacnuke", col = 1, row = 2, tooltip = "Launch EOS (Tactical Nuke)\nTactical nuclear missile with high damage."}, + {id = 39611, name = "Seismic", icon = "seismic", col = 2, row = 2, tooltip = "Launch Seismic\nArea denial seismic missile, slows units."}, + {id = 39612, name = "Shockley", icon = "empmissile", col = 3, row = 2, tooltip = "Launch Shockley (EMP)\nElectromagnetic pulse missile disables units."}, + {id = 39613, name = "Inferno", icon = "napalmmissile", col = 4, row = 2, tooltip = "Launch Inferno (Napalm)\nNapalm missile with persistent damage."}, + {id = 39616, name = "Zeno", icon = "missileslow", col = 5, row = 2, tooltip = "Launch Zeno (Slow Missile)\nSlow homing missile with lingering damage."}, } +local missileCmdPos = {} +for _, missile in ipairs(missileCmds) do + missileCmdPos[missile.id] = {col = missile.col, row = missile.row} +end + local function isMissileCommand(cmdID) - for _, missile in ipairs(missileCmds) do - if cmdID == missile.id then return true end - end - return false + return missileCmdPos[cmdID] ~= nil end -------------------------------------------------------------------------------- @@ -498,7 +503,8 @@ for _, missile in ipairs(missileCmds) do local icon = unitDef and ("#" .. unitDef.id) or (imageDir .. 'Bold/attack.png') commandDisplayConfig[missile.id] = { texture = icon, - tooltip = missile.tooltip + tooltip = missile.tooltip, + drawName = true, -- show the stockpile count / build progress string (set by the missile widget) } end @@ -533,7 +539,8 @@ local commandPanels = { name = "missiles", inclusionFunction = function(cmdID) if not hasMissileUnits() then return false end - return isMissileCommand(cmdID) + local pos = missileCmdPos[cmdID] + return pos ~= nil, pos end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index bb1bade1c2..a9aa08d513 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -122,6 +122,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 @@ -554,6 +561,7 @@ local buttonsByCommand = {} local alreadyRemovedTag = {} local lastRemovedTagResetFrame = false + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Utility @@ -2551,6 +2559,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 @@ -2593,7 +2612,8 @@ function widget:Update() UpdateButtonSelection(cmdID) UpdateReturnToOrders(cmdID) - -- Update tab badges and visibility + -- Update tab badges. Tab presence/visibility is handled by the + -- commandCount + SetTabs machinery, driven by each panel's inclusionFunction. for i = 1, #commandPanels do local panelData = commandPanels[i] @@ -2602,19 +2622,6 @@ function widget:Update() local count = WG[panelData.badgeCountWG] or 0 panelData.tabButton:UpdateBadgeCount(count) end - - -- Update tab visibility for panels with dynamic visibility - if panelData.name == "missiles" and panelData.tabButton and panelData.tabButton.button then - local hasCommands = false - local customCommands = widgetHandler.customCommands - for j = 1, #customCommands do - if panelData.inclusionFunction(customCommands[j].id) then - hasCommands = true - break - end - end - panelData.tabButton.button:SetVisibility(hasCommands) - end end end diff --git a/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua b/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua index 0ce9c0c8ec..f06e282a71 100644 --- a/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua +++ b/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua @@ -2068,8 +2068,8 @@ local function GetSingleUnitInfoPanel(parentControl, isTooltipVersion) local healthPos if shieldBarUpdate then - if ud and (ud.shieldPower > 0 or ud.level) then - local shieldPower = (spGetUnitRulesParam(unitID, "comm_shield_max") or ud.shieldPower) * (Spring.GetUnitRulesParam(unitID, "totalShieldMaxMult") or 1) + if ud and ((ud.shieldPower or 0) > 0 or ud.level) then + 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/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 303c92b183..6b96a8b926 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -10,7 +10,7 @@ function widget:GetInfo() license = "GNU GPL, v2 or later", layer = 0, handler = true, - enabled = false, + enabled = true, } end @@ -21,7 +21,86 @@ end -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -VFS.Include(LUAUI_DIRNAME.."Widgets/Utilities/engine_blast_radius.lua") +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 GL_LINE_LOOP = GL.LINE_LOOP + +local circleDivs = 64 + +local PI = math.pi +local cos = math.cos +local sin = math.sin + +local aoeLineWidthMult = 64 +local numAoECircles = 9 +local aoeColor = {1, 0, 0, 1} +local mouseDistance = 1000 +local floor = math.floor + +local pulse_timmer = Spring.GetTimer() +local function getPulse() + local time = Spring.DiffTimers(Spring.GetTimer(), pulse_timmer) + return 1 - (time - floor(time)) +end + +local function UnitCircleVertices() + for i = 1, circleDivs do + local theta = 2 * PI * i / circleDivs + glVertex(cos(theta), 0, sin(theta)) + end +end + +local function DrawCircle(x, y, z, radius) + glPushMatrix() + glTranslate(x, y, z) + glScale(radius, radius, radius) + glBeginEnd(GL_LINE_LOOP, UnitCircleVertices) + glPopMatrix() +end + +local function drawBlastRadius(tx, ty, tz, weaponDef) + local aoe = weaponDef.damageAreaOfEffect + local ee = weaponDef.edgeEffectiveness + + glLineWidth(math.max(0.05, aoeLineWidthMult * aoe / mouseDistance)) + + for i = 1, numAoECircles do + local proportion = i / (numAoECircles + 1) + local radius = aoe * proportion + local alpha = aoeColor[4] * (1 - proportion) / (1 - proportion * ee) * getPulse() + glColor(aoeColor[1], aoeColor[2], aoeColor[3], alpha) + DrawCircle(tx, ty, tz, radius) + end + + glColor(1,1,1,1) + glLineWidth(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 -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -123,9 +202,17 @@ local function missle_class() 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 stockpile build percentage. + local _, _, stockpileProgress = Spring.GetUnitStockpile(unitID) + if stockpileProgress and stockpileProgress > 0 and stockpileProgress < 1 then + maxProgress = math.max(maxProgress, stockpileProgress) + end end end end @@ -237,10 +324,13 @@ local function missle_class() local customCommands = widgetHandler.customCommands customCommands[#customCommands+1] = { - id = self.cmd, - type = self.cmdType, - hidden = true, - cursor = 'Attack', + id = self.cmd, + type = self.cmdType, + cursor = 'Attack', + action = "missile_" .. self.name, + name = self.displayName, + disabled = self.disabled, + params = {}, } end @@ -532,42 +622,6 @@ local commands = { local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 -local buttonCache = {} - -local missileCommandIDs = { - [39610] = true, - [39611] = true, - [39612] = true, - [39613] = true, - [39614] = true, - [39615] = true, - [39616] = true, -} - -local function findButtonsByCommand() - local screen = WG.Chili.Screen0 - if not screen then return end - - local function searchChildren(control) - if not control then return end - - if control.cmdID and missileCommandIDs[control.cmdID] then - buttonCache[control.cmdID] = control - end - - if control.children then - for _, child in ipairs(control.children) do - searchChildren(child) - end - end - end - - if screen.children then - for _, child in ipairs(screen.children) do - searchChildren(child) - end - end -end -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -585,48 +639,44 @@ function widget:Update(dt) end timer = 0 - findButtonsByCommand() - local totalMissileCount = 0 + local changed = false for _, command in pairs(commands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() - local customCommands = widgetHandler.customCommands totalMissileCount = totalMissileCount + count - for i = 1, #customCommands do - if customCommands[i].id == command.cmd then - local displayName = "" - if count > 0 then - displayName = "x" .. count - end - if buildProgress > 0 then - local progressPercent = math.floor(buildProgress * 100) - if displayName ~= "" then - displayName = displayName .. " (" .. progressPercent .. "%)" - else - displayName = progressPercent .. "%" - end - end - customCommands[i].name = displayName + -- Count string shown on the button (e.g. "x3"), empty when none stockpiled. + -- This is drawn by the integral menu via the command's name field (see + -- DRAW_NAME_COMMANDS / commandDisplayConfig.drawName). + local displayName = "" + if count > 0 then + displayName = "x" .. count + end - -- Disable button if no missiles available - customCommands[i].disabled = (count == 0) + -- Factory-style build progress bar on the button. + if WG.IntegralMenu and WG.IntegralMenu.SetCommandProgress then + WG.IntegralMenu.SetCommandProgress(command.cmd, buildProgress) + end - -- Update visual progress bar on button - local button = buttonCache[command.cmd] - if button and button.SetProgressBar then - button:SetProgressBar(buildProgress) - end - break - end + local disabled = (count == 0) + if command.displayName ~= displayName or command.disabled ~= disabled then + command.displayName = displayName + command.disabled = disabled + changed = true end end -- Export total count for tab badge WG.missileTotalCount = totalMissileCount + + -- The integral menu only re-reads custom commands on CommandsChanged, so force + -- a layout update when the displayed count / progress actually changed. + if changed then + Spring.ForceLayoutUpdate() + end end function widget:CommandNotify(cmdID, cmdParams, cmdOptions) From 40349a361e7adacde76a2869dc44f6d8ae60f9d2 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 1 Jul 2026 10:43:16 -0500 Subject: [PATCH 22/31] Fix stockpile build progress source for missile buttons Zero-K reimplements stockpiling in unit_stockpile.lua, which pins the engine's stockpile build percent to 1 and tracks real progress in the "gadgetStockpile" rules param. Read that param for stockpiling weapons (Trinity, Reef, subtac) instead of GetUnitStockpile, matching how unit_healthbars / gui_build_eta / gui_nukeButton do it. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/missle_command_center.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 6b96a8b926..c627c7411d 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -208,8 +208,10 @@ local function missle_class() maxProgress = math.max(maxProgress, buildProgress) else -- Stockpiling weapons (Trinity, Reef, subtac) report progress toward - -- the next missile via the stockpile build percentage. - local _, _, stockpileProgress = Spring.GetUnitStockpile(unitID) + -- 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 From fe9a8e8c33dfec12631aadf481de4aab0a42b8bc Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 1 Jul 2026 11:07:30 -0500 Subject: [PATCH 23/31] Keep missiles tab visible and usable with no selection - Missile widget forces a layout rebuild once when the selection becomes empty, since the command menu pipeline does not run on its own then. - Integral menu falls back to the first shown tab when the intended tab (default "orders") is not present, so the missiles panel gets selected. - Add alwaysShowTab panel flag (set on missiles) to draw the tab bar even when it is the only tab. - Guard both return-to-orders paths with IsTabPresent so firing a missile with nothing selected no longer switches to an empty orders tab and clears the panel. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Configs/integral_menu_config.lua | 1 + LuaUI/Widgets/gui_chili_integral_menu.lua | 31 +++++++++++++++++++---- LuaUI/Widgets/missle_command_center.lua | 11 +++++--- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 7619dd2606..37457a48bd 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -543,6 +543,7 @@ local commandPanels = { return pos ~= nil, pos end, loiterable = true, + alwaysShowTab = true, buttonLayoutConfig = buttonLayoutConfig.command, badgeUnitName = "tacnuke", badgeCountWG = "missileTotalCount", diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index a9aa08d513..d7132b0e50 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -605,7 +605,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 @@ -2232,10 +2234,14 @@ 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 tabsToShow[#tabsToShow + 1] = data.tabButton + if data.alwaysShowTab then + forceShowTabs = true + end data.buttons.ClearOldButtons(selectionIndex) if data.queue then data.queue.ClearOldButtons(selectionIndex) @@ -2254,15 +2260,28 @@ local function ProcessAllCommands(commands, customCommands) if not tabToSelect then tabToSelect = "orders" end - + if #tabsToShow == 0 then 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)) end @@ -2340,7 +2359,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 diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index c627c7411d..6d03d8672c 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -624,6 +624,7 @@ local commands = { local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 +local wasEmptySelection = false -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -674,11 +675,15 @@ function widget:Update(dt) -- Export total count for tab badge WG.missileTotalCount = totalMissileCount - -- The integral menu only re-reads custom commands on CommandsChanged, so force - -- a layout update when the displayed count / progress actually changed. - if changed then + -- 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 function widget:CommandNotify(cmdID, cmdParams, cmdOptions) From 1bfac1444ff56a39c19d54c85e48362a3ea08285 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Thu, 2 Jul 2026 00:26:13 -0500 Subject: [PATCH 24/31] Add second tab row for the missiles tab - Split the integral menu tab strip into a top and bottom row. The missiles tab (flagged topRow) sits on the top row; all other tabs on the bottom. - When there are no bottom-row tabs, the missiles tab drops into the single bottom row so it stays adjacent to the command buttons. - Grow the window upward by one tab row when both rows are used, keeping the window bottom pinned to the screen edge and the command-button area fixed (bottom-anchored with a constant height). Co-Authored-By: Claude Opus 4.8 --- LuaUI/Configs/integral_menu_config.lua | 1 + LuaUI/Widgets/gui_chili_integral_menu.lua | 114 ++++++++++++++++++---- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 37457a48bd..6b48fbe8bf 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -544,6 +544,7 @@ local commandPanels = { end, loiterable = true, alwaysShowTab = true, + topRow = true, buttonLayoutConfig = buttonLayoutConfig.command, badgeUnitName = "tacnuke", badgeCountWG = "missileTotalCount", diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index d7132b0e50..4a9e05cd73 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -137,6 +137,7 @@ local returnToOrdersCommand = false local simpleModeEnabled = true local buildTabHolder, buttonsHolder -- Required for padding update setting +local mainWindow, baseWindowHeight, buttonAreaHeight -- Required for growing the menu for a second tab row -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Widget Options @@ -1980,11 +1981,14 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit end local function GetTabPanel(parent, rows, columns) - local tabHolder = StackPanel:New{ + -- Two tab rows: the top row is used by panels flagged topRow (the missiles + -- tab), the bottom row by everything else. When only one row has tabs it + -- fills the whole tab area; when both are used the menu grows taller. + local topHolder = StackPanel:New{ x = 0, - y = 0, + y = "0%", right = 0, - bottom = 0, + height = "100%", padding = {0, 0, 0, 0}, itemMargin = {0, 1, 0, -1}, parent = parent, @@ -1992,12 +1996,54 @@ local function GetTabPanel(parent, rows, columns) resizeItems = true, orientation = "horizontal", } - + local bottomHolder = StackPanel:New{ + x = 0, + y = "0%", + right = 0, + height = "100%", + padding = {0, 0, 0, 0}, + itemMargin = {0, 1, 0, -1}, + parent = parent, + preserveChildrenOrder = true, + resizeItems = true, + orientation = "horizontal", + } + + local function SetRowGeometry(topActive, bottomActive) + local twoRows = topActive and bottomActive + if twoRows then + topHolder._relativeBounds.top = "0%" + topHolder._relativeBounds.height = "50%" + bottomHolder._relativeBounds.top = "50%" + bottomHolder._relativeBounds.height = "50%" + else + topHolder._relativeBounds.top = "0%" + topHolder._relativeBounds.height = "100%" + bottomHolder._relativeBounds.top = "0%" + bottomHolder._relativeBounds.height = "100%" + end + topHolder:UpdateClientArea() + bottomHolder:UpdateClientArea() + + -- Grow the window upward by one tab row when both rows are used, keeping + -- the window bottom pinned to the screen edge (extend the top upward + -- rather than letting the bottom rise). The button area is bottom-anchored + -- with a fixed height, so only the tab area changes size. + if mainWindow and baseWindowHeight then + local newHeight = twoRows and (baseWindowHeight * 8/7) or baseWindowHeight + local parentHeight = (mainWindow.parent and mainWindow.parent.height) or (mainWindow.y + mainWindow.height) + mainWindow:SetPos(nil, parentHeight - newHeight, nil, newHeight) + buildTabHolder:UpdateClientArea() + topHolder:UpdateClientArea() + bottomHolder:UpdateClientArea() + end + end + local currentSelectedIndex local hotkeysActive = true local currentTab local tabList = false - + local externalFunctions = {} function externalFunctions.SwitchToTab(name) @@ -2022,10 +2068,31 @@ local function GetTabPanel(parent, rows, columns) tabList[currentSelectedIndex].SetSelected(false) end tabList = newTabList - tabHolder:ClearChildren() + topHolder:ClearChildren() + bottomHolder:ClearChildren() + + -- Only use the top row when there are also bottom-row tabs; otherwise the + -- top-row (missiles) tab drops down into the single bottom row. + local hasBottom = false + if showTabs then + for i = 1, #tabList do + if not tabList[i].topRow then + hasBottom = true + break + end + end + end + + local topActive, bottomActive = false, false for i = 1, #tabList do if showTabs then - tabHolder:AddChild(tabList[i].button) + if tabList[i].topRow and hasBottom then + topHolder:AddChild(tabList[i].button) + topActive = true + else + bottomHolder:AddChild(tabList[i].button) + bottomActive = true + end tabList[i].SetHideHotkey(variableHide) tabList[i].SetHotkeyActive(hotkeysActive) end @@ -2033,6 +2100,7 @@ local function GetTabPanel(parent, rows, columns) tabList[i].DoClick() end end + SetRowGeometry(topActive, bottomActive) end function externalFunctions.ClearTabs() @@ -2040,7 +2108,9 @@ local function GetTabPanel(parent, rows, columns) externalFunctions.SwitchToTab() tabList = false currentSelectedIndex = false - tabHolder:ClearChildren() + topHolder:ClearChildren() + bottomHolder:ClearChildren() + SetRowGeometry(false, false) end end @@ -2297,10 +2367,15 @@ local function InitializeControls() local screenWidth, screenHeight = spGetViewGeometry() local width = math.max(350, math.min(450, screenWidth*screenHeight*0.0004)) local height = math.min(screenHeight/4.5, 200*width/450) + 8 + baseWindowHeight = height + -- The command-button area is bottom-anchored with a fixed height so it never + -- moves when the tab area grows/shrinks for a second tab row. One tab row is + -- baseHeight/7, matching the original 100/7% tab strip. + buttonAreaHeight = height * 6/7 gridKeyMap, gridMap, gridCustomOverrides = GenerateGridKeyMap(options.keyboardType2.value) - - local mainWindow = Window:New{ + + mainWindow = Window:New{ name = 'integralwindow', x = 0, bottom = 0, @@ -2325,27 +2400,27 @@ local function InitializeControls() x = options.leftPadding.value, y = "0%", right = options.rightPadding.value, - height = "15%", + bottom = buttonAreaHeight, padding = {2, 2, 2, -1}, parent = mainWindow, } - + tabPanel = GetTabPanel(buildTabHolder) - + buttonsHolder = Control:New{ x = options.leftPadding.value, - y = (100/7) .. "%", - right = options.rightPadding.value, bottom = 0, + right = options.rightPadding.value, + height = buttonAreaHeight, padding = {0, 0, 0, 0}, parent = mainWindow, } - + background = Panel:New{ x = 0, - y = "15%", - right = 0, bottom = 0, + right = 0, + height = buttonAreaHeight, draggable = false, resizable = false, noFont = true, @@ -2417,7 +2492,8 @@ local function InitializeControls() end data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect, {unitName = data.badgeUnitName, countWG = data.badgeCountWG}) - + data.tabButton.topRow = data.topRow + if data.gridHotkeys and ((not data.disableableKeys) or options.unitsHotkeys2.value) then data.buttons.ApplyGridHotkeys(gridMap, (gridCustomOverrides and gridCustomOverrides[data.name]) or {}) end From afe7a9eae6370fea1d794ee0a436bfd637d47769 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Thu, 2 Jul 2026 00:43:13 -0500 Subject: [PATCH 25/31] Fix missile launch order and command descriptor - Insert launches after existing launch orders but before other queued orders (e.g. moves), so shift-clicked launches fire in click order and still go before the unit moves away. Previously they inserted at queue position 0, reversing the click order. - Give the launch command descriptor all required fields (name, texture, tooltip, boolean disabled) so the engine no longer logs "GetLuaCmdDescList() bad entry". Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/missle_command_center.lua | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 6d03d8672c..91ed7d1272 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -325,13 +325,18 @@ local function missle_class() function self:commandsChanged() 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, - name = self.displayName, - disabled = self.disabled, + texture = "LuaUI/Images/commands/Bold/missile.png", + tooltip = "Launch missile.", + disabled = self.disabled or false, params = {}, } end @@ -349,7 +354,21 @@ local function missle_class() local unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] if not unitType then return true end - Spring.GiveOrderToUnit(unit, CMD.INSERT, {0, unitType.launchCmd, CMD.OPT_SHIFT, unpack(cmdParams)}, CMD.OPT_ALT) + -- 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 From 7f56fb65e6f24619151c4ccea5008b03c46cfdf3 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Thu, 2 Jul 2026 11:03:07 -0500 Subject: [PATCH 26/31] Missiles tab badge: per-type icons with count and build progress - Replace the single tacnuke icon + total count badge with a right-aligned row of per-type entries, one for each missile type that is stockpiled or building. Each entry shows the unit icon, a build-progress bar overlay, and the stockpile count. Icons are created with their file to avoid rendering as white quads. - Export per-type {icon, count, progress} from the missile widget in a stable order instead of a single total. - When a unit is (re)selected while on the missiles tab, switch to that unit's default tab instead of loitering on the global top-row tab. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Configs/integral_menu_config.lua | 3 +- LuaUI/Widgets/gui_chili_integral_menu.lua | 148 ++++++++++++++++------ LuaUI/Widgets/missle_command_center.lua | 31 ++++- 3 files changed, 132 insertions(+), 50 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 6b48fbe8bf..e58dc0d1f4 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -546,8 +546,7 @@ local commandPanels = { alwaysShowTab = true, topRow = true, buttonLayoutConfig = buttonLayoutConfig.command, - badgeUnitName = "tacnuke", - badgeCountWG = "missileTotalCount", + badgeIconsWG = "missileActiveIcons", gridHotkeys = true, returnOnClick = "orders", }, diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index 4a9e05cd73..35e729622c 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -132,6 +132,7 @@ end local statePanel = {} local tabPanel local selectionIndex = 0 +local lastSelectionSignature = false -- to detect selection changes for tab defaulting local background local returnToOrdersCommand = false local simpleModeEnabled = true @@ -1873,45 +1874,99 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit button = button, name = name, DoClick = DoClick, - badgePanel = nil, } - -- Create badge if configured - if badgeConfig and badgeConfig.unitName then - local unitDef = UnitDefNames[badgeConfig.unitName] - if unitDef then - externalFunctionsAndData.badgePanel = Panel:New { - x = "100%-30", - y = 0, - width = 30, - height = 30, - parent = button, - } - - Image:New { - x = 0, - y = 0, - width = 20, - height = 20, - file = "#" .. unitDef.id, - parent = externalFunctionsAndData.badgePanel, - } - - externalFunctionsAndData.badgeLabel = Label:New { - x = 20, - y = 0, - width = 10, - height = 20, - caption = "0", - align = "left", - valign = "center", - fontSize = 10, - parent = externalFunctionsAndData.badgePanel, - } + -- Create a badge showing a row of icons after the label (one per active + -- missile type). The icon list is supplied each update via UpdateBadgeIcons. + if badgeConfig and badgeConfig.iconsWG then + local BADGE_ICON_SIZE = 18 + local BADGE_COUNT_WIDTH = 14 + local BADGE_ENTRY_WIDTH = BADGE_ICON_SIZE + BADGE_COUNT_WIDTH + local badgeIcons = {} + local badgeLabels = {} + local badgeBars = {} + local lastBadgeKey = false + local lastBadgeWidth = false + + -- Each entry (icon + count) is parented directly to the button (no + -- covering panel) so the tab stays clickable; the row is right-aligned so + -- it follows the label. + function externalFunctionsAndData.UpdateBadgeIcons(list) + list = list or {} + local width = button.width or 0 + local keyParts = {} + for i = 1, #list do + keyParts[i] = list[i].icon .. ":" .. list[i].count .. ":" .. math.floor((list[i].progress or 0) * 100) + end + local key = table.concat(keyParts, ",") + if key == lastBadgeKey and width == lastBadgeWidth then + return + end + lastBadgeKey = key + lastBadgeWidth = width + + local n = #list + local startX = width - 2 - n * BADGE_ENTRY_WIDTH + local y = math.max(0, ((button.height or BADGE_ICON_SIZE) - BADGE_ICON_SIZE) / 2) + for i = 1, math.max(n, #badgeIcons) do + if i <= n then + local entryX = startX + (i - 1) * BADGE_ENTRY_WIDTH + if not badgeIcons[i] then + badgeIcons[i] = Image:New { + width = BADGE_ICON_SIZE, + height = BADGE_ICON_SIZE, + file = list[i].icon, + parent = button, + } + -- Build progress bar overlaying the icon (like command buttons). + badgeBars[i] = Progressbar:New { + 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}, + parent = badgeIcons[i], + skin = nil, + skinName = 'default', + } + badgeLabels[i] = Label:New { + width = BADGE_COUNT_WIDTH, + height = BADGE_ICON_SIZE, + align = "left", + valign = "center", + fontSize = 10, + parent = button, + } + end + badgeIcons[i].file = list[i].icon + badgeIcons[i]:SetPos(entryX, y) + badgeIcons[i]:SetVisibility(true) + badgeIcons[i]:Invalidate() + + local progress = list[i].progress or 0 + if progress > 0 then + badgeBars[i]:SetValue(progress) + badgeBars[i]:SetVisibility(true) + else + badgeBars[i]:SetVisibility(false) + end - function externalFunctionsAndData.UpdateBadgeCount(count) - if externalFunctionsAndData.badgeLabel then - externalFunctionsAndData.badgeLabel:SetCaption(tostring(count)) + badgeLabels[i]:SetCaption((list[i].count > 0) and tostring(list[i].count) or "") + badgeLabels[i]:SetPos(entryX + BADGE_ICON_SIZE, y) + badgeLabels[i]:SetVisibility(true) + badgeLabels[i]:Invalidate() + else + if badgeIcons[i] then + badgeIcons[i]:SetVisibility(false) + end + if badgeLabels[i] then + badgeLabels[i]:SetVisibility(false) + end end end end @@ -2243,6 +2298,12 @@ local function ProcessAllCommands(commands, customCommands) local factoryUnitID, factoryUnitDefID, fakeFactory, selectedUnitCount = GetSelectionValues() local unitMobilePanelSize = GetUnitMobilePanelSize(commands, factoryUnitDefID) + -- Detect an actual selection change (vs a command-only refresh) so that + -- selecting a unit while on a global tab (missiles) switches to its default. + local selectionSignature = table.concat(spGetSelectedUnits(), ",") + local selectionChanged = (selectionSignature ~= lastSelectionSignature) + lastSelectionSignature = selectionSignature + selectionIndex = selectionIndex + 1 for i = 1, #commandPanels do @@ -2317,7 +2378,11 @@ local function ProcessAllCommands(commands, customCommands) data.queue.ClearOldButtons(selectionIndex) end if (not tabToSelect) and data.tabButton.name == lastTabSelected then - tabToSelect = lastTabSelected + -- When a unit is (re)selected, do not loiter on a global top-row + -- tab (missiles); fall through to the unit's default tab instead. + if not (selectionChanged and selectedUnitCount > 0 and data.topRow) then + tabToSelect = lastTabSelected + end end end end @@ -2491,7 +2556,7 @@ local function InitializeControls() end end - data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect, {unitName = data.badgeUnitName, countWG = data.badgeCountWG}) + data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect, {iconsWG = data.badgeIconsWG}) data.tabButton.topRow = data.topRow if data.gridHotkeys and ((not data.disableableKeys) or options.unitsHotkeys2.value) then @@ -2714,10 +2779,9 @@ function widget:Update() for i = 1, #commandPanels do local panelData = commandPanels[i] - -- Update badge count - if panelData.badgeCountWG and panelData.tabButton and panelData.tabButton.UpdateBadgeCount then - local count = WG[panelData.badgeCountWG] or 0 - panelData.tabButton:UpdateBadgeCount(count) + -- Update badge icons (one per active missile type) + if panelData.badgeIconsWG and panelData.tabButton and panelData.tabButton.UpdateBadgeIcons then + panelData.tabButton.UpdateBadgeIcons(WG[panelData.badgeIconsWG]) end end end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 91ed7d1272..959f80a3b1 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -15,7 +15,7 @@ function widget:GetInfo() end function widget:Initialize() - WG.missileTotalCount = 0 + WG.missileActiveIcons = {} end -------------------------------------------------------------------------------- @@ -641,6 +641,21 @@ local commands = { trinityMissile = trinity_missile_controller_class(), } +-- Stable order for the tab badge icons: Trinity, Reef, then the silo missiles. +local orderedCommands = { + commands.trinityMissile, + commands.reefMissile, + commands.EOS, + commands.seismic, + commands.shockley, + commands.inferno, + commands.slowMissile, +} +for _, command in ipairs(orderedCommands) do + local unitDef = UnitDefNames[command.name] + command.iconTexture = unitDef and ("#" .. unitDef.id) or nil +end + local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 local wasEmptySelection = false @@ -661,14 +676,18 @@ function widget:Update(dt) end timer = 0 - local totalMissileCount = 0 local changed = false + local activeIcons = {} - for _, command in pairs(commands) do + for _, command in ipairs(orderedCommands) do local count = command:getCount() local buildProgress = command:getMaxBuildProgress() - totalMissileCount = totalMissileCount + count + -- Tab badge: an icon + count + build progress per missile type stockpiled + -- or building. + if command.iconTexture and (count >= 1 or buildProgress > 0) then + activeIcons[#activeIcons + 1] = {icon = command.iconTexture, count = count, progress = buildProgress} + end -- Count string shown on the button (e.g. "x3"), empty when none stockpiled. -- This is drawn by the integral menu via the command's name field (see @@ -691,8 +710,8 @@ function widget:Update(dt) end end - -- Export total count for tab badge - WG.missileTotalCount = totalMissileCount + -- 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. From dbfbda81f617e1414a142b6e8f9800d4fded51d3 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Fri, 3 Jul 2026 18:11:46 -0500 Subject: [PATCH 27/31] Remove misleading (N) hotkey label from tabs without their own hotkey Tabs without an optionName (missiles, orders, units_factory) borrowed the Units any+n hotkey for display, showing "(N)" even though N is the hold-fire key and never switches to them. Drop the display fallback so they show no hotkey label. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/gui_chili_integral_menu.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index 35e729622c..3dcb07a83a 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -95,7 +95,6 @@ local NO_TEXT = "" local NO_TOOLTIP = "NONE" EPIC_NAME = "epic_chili_integral_menu_" -EPIC_NAME_UNITS = "epic_chili_integral_menu_tab_units" local modOptions = Spring.GetModOptions() local disabledTabs = {} @@ -2521,11 +2520,12 @@ 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) - else - hotkey = GetActionHotkey(EPIC_NAME_UNITS) end if data.returnOnClick then From 607809267c33923f2a922833572636073e81714c Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 4 Jul 2026 10:16:45 -0500 Subject: [PATCH 28/31] Missile launch preview: relocate blast ring to terrain that blocks the shot Adds an approximate pre-fire terrain-block check for starburst silo missiles. The descent is modelled as a straight line from an apex above the launcher to the target; if terrain rises into that line the blast ring is moved to the hit point, with a faint ghost ring left at the intended target. Apex height is learned per weapon from real shots, falling back to a range-based estimate. Checkpoint before a more accurate trajectory model. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/missle_command_center.lua | 116 +++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 959f80a3b1..0d5195649a 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -46,6 +46,23 @@ local numAoECircles = 9 local aoeColor = {1, 0, 0, 1} local mouseDistance = 1000 local floor = math.floor +local sqrt = math.sqrt + +local spGetGroundHeight = Spring.GetGroundHeight +local spGetProjectilePosition = Spring.GetProjectilePosition + +-- Terrain-block prediction (starburst silo missiles only). The descent is +-- modelled as a straight line from an apex directly above the launcher down to +-- the target; the apex is kept below the real (higher, curved) trajectory so the +-- check errs toward "blocked", never a false "clear". See notes at predictImpact. +local SAMPLE_STEP = 24 -- elmos between terrain samples along the descent line +local BLOCK_EPSILON = 12 -- ground must exceed the line by this much to block +local TARGET_SKIP = 0.92 -- ignore the near-target dive; it comes down steeply +local APEX_SAFETY = 0.85 -- fraction of the observed peak used as the model apex + +local learnedApex = {} -- weaponDefID -> apex height above launch ground +local launchWeaponDefs = {} -- weaponDefID -> true (starburst launch weapons to learn from) +local flightTrack = {} -- proID -> {wdid, baseY, peak} local pulse_timmer = Spring.GetTimer() local function getPulse() @@ -86,6 +103,15 @@ local function drawBlastRadius(tx, ty, tz, weaponDef) 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") @@ -133,6 +159,35 @@ local function distance(x1,z1,x2,z2) return (x1-x2)*(x1-x2)+(z1-z2)*(z1-z2) end +-- Modelled apex height above the launcher. Learned from real shots once seen; +-- until then a rough scale from range (missiles with more range lob higher). +local function getApex(weaponDefID, weaponDef) + return learnedApex[weaponDefID] or (weaponDef.range or 3000) * 0.3 +end + +-- Walk the straight descent line from apex-above-launcher to the target, +-- sampling terrain. Returns the first point where ground rises into the path +-- (impactX, impactY, impactZ, true), or the target and false if it stays clear. +local function predictImpact(sx, sz, tx, ty, tz, apex) + local apexY = spGetGroundHeight(sx, sz) + apex + local dx, dz = tx - sx, tz - sz + local dist = sqrt(dx*dx + dz*dz) + if dist < 1 then return tx, ty, tz, false end + local steps = floor(dist / SAMPLE_STEP) + for i = 1, steps do + local f = i / steps + if f > TARGET_SKIP then break end + local px = sx + dx * f + local pz = sz + dz * f + local lineY = apexY + (ty - apexY) * f + local groundY = spGetGroundHeight(px, pz) + if groundY > lineY + BLOCK_EPSILON then + return px, groundY, pz, true + end + end + return tx, ty, tz, false +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -415,8 +470,20 @@ local function missle_class() local range = weaponDef.range if dist > range * range then return end - drawBlastRadius(mx, my, mz, weaponDef) - drawLine(ux, uy, uz, mx, my, mz) + -- For lobbed (starburst) missiles, relocate the impact to any terrain that + -- blocks the descent so the ring lands where the missile actually would. + local ix, iy, iz = mx, my, mz + local blocked = false + if weaponDef.type == "StarburstLauncher" then + local apex = getApex(weapon.weaponDef, weaponDef) + ix, iy, iz, blocked = predictImpact(ux, uz, mx, my, mz, apex) + end + + if blocked then + drawGhostTarget(mx, my, mz, weaponDef) + end + drawBlastRadius(ix, iy, iz, weaponDef) + drawLine(ux, uy, uz, ix, iy, iz) end @@ -656,6 +723,17 @@ for _, command in ipairs(orderedCommands) do command.iconTexture = unitDef and ("#" .. unitDef.id) or nil end +-- Collect the launch weapons whose real trajectory we calibrate apex from. +for _, command in pairs(commands) do + for unitDefID, launchType in pairs(command.launchableTypes) do + local ud = UnitDefs[unitDefID] + local weapon = ud and ud.weapons and ud.weapons[launchType.weaponId] + if weapon and weapon.weaponDef and WeaponDefs[weapon.weaponDef].type == "StarburstLauncher" then + launchWeaponDefs[weapon.weaponDef] = true + end + end +end + local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 local wasEmptySelection = false @@ -737,3 +815,37 @@ function widget:DrawWorld() end end +-------------------------------------------------------------------------------- +-- Calibration: learn each starburst missile's real apex height from live shots. +-------------------------------------------------------------------------------- + +function widget:ProjectileCreated(proID, proOwnerID, weaponDefID) + if not launchWeaponDefs[weaponDefID] then return end + local x, y, z = spGetProjectilePosition(proID) + if not x then return end + flightTrack[proID] = {wdid = weaponDefID, baseY = spGetGroundHeight(x, z), peak = y} +end + +function widget:GameFrame() + if not next(flightTrack) then return end + for proID, data in pairs(flightTrack) do + local _, y = spGetProjectilePosition(proID) + if y then + if y > data.peak then + data.peak = y + elseif y < data.peak - 40 then + -- Past the apex: record height gained above launch ground and stop. + local apex = (data.peak - data.baseY) * APEX_SAFETY + if apex > 0 then learnedApex[data.wdid] = apex end + flightTrack[proID] = nil + end + else + flightTrack[proID] = nil + end + end +end + +function widget:ProjectileDestroyed(proID) + flightTrack[proID] = nil +end + From 1bb6c0e9d0358f3bd41979630341b619967b8766 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 4 Jul 2026 10:53:49 -0500 Subject: [PATCH 29/31] Missile block preview: learn crest distance for a truer dive angle The descent line previously started directly above the launcher, making it too shallow and prone to over-warning. Learn both the crest height and its horizontal distance from the launcher from real shots, and start the dive line from that crest point so its angle matches the missile's actual trajectory. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/missle_command_center.lua | 82 ++++++++++++++++--------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missle_command_center.lua index 0d5195649a..7fe16bcab8 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missle_command_center.lua @@ -47,22 +47,26 @@ local aoeColor = {1, 0, 0, 1} local mouseDistance = 1000 local floor = math.floor local sqrt = math.sqrt +local min = math.min local spGetGroundHeight = Spring.GetGroundHeight local spGetProjectilePosition = Spring.GetProjectilePosition --- Terrain-block prediction (starburst silo missiles only). The descent is --- modelled as a straight line from an apex directly above the launcher down to --- the target; the apex is kept below the real (higher, curved) trajectory so the --- check errs toward "blocked", never a false "clear". See notes at predictImpact. -local SAMPLE_STEP = 24 -- elmos between terrain samples along the descent line +-- Terrain-block prediction (starburst silo missiles only). A starburst goes +-- straight up, turns, then dives in a straight line to the target. We model the +-- dive as a line from the crest (apex height, part-way toward the target) down +-- to the target, and sample terrain under it. Both the crest height and its +-- horizontal distance from the launcher are learned from real shots, so the +-- dive angle matches what the missile actually flies. +local SAMPLE_STEP = 24 -- elmos between terrain samples along the dive line local BLOCK_EPSILON = 12 -- ground must exceed the line by this much to block -local TARGET_SKIP = 0.92 -- ignore the near-target dive; it comes down steeply -local APEX_SAFETY = 0.85 -- fraction of the observed peak used as the model apex +local TARGET_SKIP = 0.92 -- ignore the last bit of the dive; it comes down steeply +local APEX_SAFETY = 0.9 -- fraction of the observed crest height used by the model -local learnedApex = {} -- weaponDefID -> apex height above launch ground +-- weaponDefID -> {h = crest height above launch ground, d = crest distance from launcher} +local learnedApex = {} local launchWeaponDefs = {} -- weaponDefID -> true (starburst launch weapons to learn from) -local flightTrack = {} -- proID -> {wdid, baseY, peak} +local flightTrack = {} -- proID -> {wdid, baseX, baseZ, baseY, peak, peakDist} local pulse_timmer = Spring.GetTimer() local function getPulse() @@ -159,26 +163,41 @@ local function distance(x1,z1,x2,z2) return (x1-x2)*(x1-x2)+(z1-z2)*(z1-z2) end --- Modelled apex height above the launcher. Learned from real shots once seen; --- until then a rough scale from range (missiles with more range lob higher). -local function getApex(weaponDefID, weaponDef) - return learnedApex[weaponDefID] or (weaponDef.range or 3000) * 0.3 +-- Crest geometry (height above launch ground, horizontal distance from launcher) +-- of the modelled trajectory. Learned from real shots once seen; until then a +-- rough scale from range (missiles with more range lob higher and further). +local function getApexGeom(weaponDefID, weaponDef) + local learned = learnedApex[weaponDefID] + if learned then + return learned.h, learned.d + end + local range = weaponDef.range or 3000 + return range * 0.3, range * 0.12 end --- Walk the straight descent line from apex-above-launcher to the target, --- sampling terrain. Returns the first point where ground rises into the path --- (impactX, impactY, impactZ, true), or the target and false if it stays clear. -local function predictImpact(sx, sz, tx, ty, tz, apex) - local apexY = spGetGroundHeight(sx, sz) + apex +-- Walk the dive line from the crest (part-way toward the target, at crest +-- height) down to the target, sampling terrain. Returns the first point where +-- ground rises into the path (impactX, impactY, impactZ, true), or the target +-- and false if it stays clear. +local function predictImpact(sx, sz, tx, ty, tz, apexH, crestDist) + local apexY = spGetGroundHeight(sx, sz) + apexH local dx, dz = tx - sx, tz - sz local dist = sqrt(dx*dx + dz*dz) if dist < 1 then return tx, ty, tz, false end - local steps = floor(dist / SAMPLE_STEP) + -- The dive starts where the missile crests: crestDist toward the target, but + -- never past the near half of a short shot. + local dEff = min(crestDist, dist * 0.6) + local nx, nz = dx / dist, dz / dist + local startX, startZ = sx + nx * dEff, sz + nz * dEff + local diveDX, diveDZ = tx - startX, tz - startZ + local diveDist = sqrt(diveDX*diveDX + diveDZ*diveDZ) + if diveDist < 1 then return tx, ty, tz, false end + local steps = floor(diveDist / SAMPLE_STEP) for i = 1, steps do local f = i / steps if f > TARGET_SKIP then break end - local px = sx + dx * f - local pz = sz + dz * f + local px = startX + diveDX * f + local pz = startZ + diveDZ * f local lineY = apexY + (ty - apexY) * f local groundY = spGetGroundHeight(px, pz) if groundY > lineY + BLOCK_EPSILON then @@ -475,8 +494,8 @@ local function missle_class() local ix, iy, iz = mx, my, mz local blocked = false if weaponDef.type == "StarburstLauncher" then - local apex = getApex(weapon.weaponDef, weaponDef) - ix, iy, iz, blocked = predictImpact(ux, uz, mx, my, mz, apex) + local apexH, crestDist = getApexGeom(weapon.weaponDef, weaponDef) + ix, iy, iz, blocked = predictImpact(ux, uz, mx, my, mz, apexH, crestDist) end if blocked then @@ -823,20 +842,27 @@ function widget:ProjectileCreated(proID, proOwnerID, weaponDefID) if not launchWeaponDefs[weaponDefID] then return end local x, y, z = spGetProjectilePosition(proID) if not x then return end - flightTrack[proID] = {wdid = weaponDefID, baseY = spGetGroundHeight(x, z), peak = y} + flightTrack[proID] = { + wdid = weaponDefID, baseX = x, baseZ = z, baseY = spGetGroundHeight(x, z), + peak = y, peakDist = 0, + } end function widget:GameFrame() if not next(flightTrack) then return end for proID, data in pairs(flightTrack) do - local _, y = spGetProjectilePosition(proID) + local x, y, z = spGetProjectilePosition(proID) if y then if y > data.peak then data.peak = y + local ddx, ddz = x - data.baseX, z - data.baseZ + data.peakDist = sqrt(ddx*ddx + ddz*ddz) elseif y < data.peak - 40 then - -- Past the apex: record height gained above launch ground and stop. - local apex = (data.peak - data.baseY) * APEX_SAFETY - if apex > 0 then learnedApex[data.wdid] = apex end + -- Past the crest: record its height and horizontal distance, then stop. + local h = (data.peak - data.baseY) * APEX_SAFETY + if h > 0 then + learnedApex[data.wdid] = {h = h, d = data.peakDist} + end flightTrack[proID] = nil end else From 47e54b8ea2b84f92f7b4a0ca61dc12c55d67aa0c Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 4 Jul 2026 11:02:52 -0500 Subject: [PATCH 30/31] Missile widget cleanups: fix names, dedupe stockpile, rename tab to Launch - Rename file and class missle -> missile; fix perfered -> preferred - Retitle widget "Missile Command Center" and update its description - Drop a dead else-end branch - Factor the five identical silo-missile getStockpile closures into one shared siloMissileStockpile helper - Rename the integral-menu tab label from "Missiles" to "Launch" Co-Authored-By: Claude Opus 4.8 --- LuaUI/Configs/integral_menu_config.lua | 2 +- ..._center.lua => missile_command_center.lua} | 135 ++++++------------ 2 files changed, 43 insertions(+), 94 deletions(-) rename LuaUI/Widgets/{missle_command_center.lua => missile_command_center.lua} (88%) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index e58dc0d1f4..aedf69519a 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -535,7 +535,7 @@ end local commandPanels = { { - humanName = "Missiles", + humanName = "Launch", name = "missiles", inclusionFunction = function(cmdID) if not hasMissileUnits() then return false end diff --git a/LuaUI/Widgets/missle_command_center.lua b/LuaUI/Widgets/missile_command_center.lua similarity index 88% rename from LuaUI/Widgets/missle_command_center.lua rename to LuaUI/Widgets/missile_command_center.lua index 7fe16bcab8..6fd9c97cf1 100644 --- a/LuaUI/Widgets/missle_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -3,8 +3,8 @@ function widget:GetInfo() return { - name = "command center: missiles", - desc = "Add missile commands to command center", + 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", @@ -210,7 +210,7 @@ end -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -local function missle_class() +local function missile_class() local self = {} self.cmdType = CMDTYPE.ICON_MAP @@ -307,7 +307,7 @@ local function missle_class() return build == 1 and count ~= 0 end - function self:perferedUnit(unit1, unit2, params) + function self:preferredUnit(unit1, unit2, params) local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) if not unit2x then return unit1 end @@ -377,7 +377,7 @@ local function missle_class() end - function self:getPerferedUnit(params) + function self:getPreferredUnit(params) local units = self:getOrderableUnits() params.selectedUnits = {} @@ -385,15 +385,15 @@ local function missle_class() params.selectedUnits[unit] = true end - local perferedUnit + local preferredUnit for _, unitID in ipairs(units) do if self:canGiveOrder(unitID) then - perferedUnit = self:perferedUnit(perferedUnit, unitID, params) + preferredUnit = self:preferredUnit(preferredUnit, unitID, params) end end - return perferedUnit + return preferredUnit end function self:commandsChanged() @@ -423,7 +423,7 @@ local function missle_class() else x,y,z = cmdParams[1], cmdParams[2], cmdParams[3] end - local unit = self:getPerferedUnit{x = x, z = z} + 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 @@ -448,7 +448,7 @@ local function missle_class() end function self:action(x, y, mouse) - if self:getCount() == 0 then return else end + if self:getCount() == 0 then return end local cmdIndex = Spring.GetCmdDescIndex(self.cmd) if not cmdIndex then return end @@ -464,7 +464,7 @@ local function missle_class() local mx, my, mz = getMouseTargetPosition() if not mx or not mz then return end - local unit = self:getPerferedUnit{x = mx, z = mz} + local unit = self:getPreferredUnit{x = mx, z = mz} if not unit then return end local ux, uy, uz = Spring.GetUnitPosition(unit) @@ -509,8 +509,27 @@ local function missle_class() 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, y1, z1 = Spring.GetUnitPosition(silo) + local x2, y2, z2 = Spring.GetUnitPosition(unit) + + if not x1 or not x2 then return 0 end + + if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end + + return 1 +end + local function EOS_controller_class() - local self = missle_class() + local self = missile_class() self.x = 438 self.y = 38 self.name = "tacnuke" @@ -520,21 +539,7 @@ local function EOS_controller_class() [UnitDefNames["tacnuke"].id] = { launchCmd = CMD.ATTACK, weaponId = 1, - getStockpile = function(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, y1, z1 = Spring.GetUnitPosition(silo) - local x2, y2, z2 = Spring.GetUnitPosition(unit) - - if not x1 or not x2 then return 0 end - - if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end - - return 1 - end + getStockpile = siloMissileStockpile }, [UnitDefNames["subtacmissile"].id] = { launchCmd = CMD.ATTACK, @@ -549,7 +554,7 @@ local function EOS_controller_class() end local function seismic_controller_class() - local self = missle_class() + local self = missile_class() self.x = 482 self.y = 38 self.name = "seismic" @@ -559,21 +564,7 @@ local function seismic_controller_class() [UnitDefNames["seismic"].id] = { launchCmd = CMD.ATTACK, weaponId = 1, - getStockpile = function(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, y1, z1 = Spring.GetUnitPosition(silo) - local x2, y2, z2 = Spring.GetUnitPosition(unit) - - if not x1 or not x2 then return 0 end - - if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end - - return 1 - end + getStockpile = siloMissileStockpile }, } @@ -581,7 +572,7 @@ local function seismic_controller_class() end local function shockley_controller_class() - local self = missle_class() + local self = missile_class() self.x = 526 self.y = 38 self.name = "empmissile" @@ -591,21 +582,7 @@ local function shockley_controller_class() [UnitDefNames["empmissile"].id] = { launchCmd = CMD.ATTACK, weaponId = 1, - getStockpile = function(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, y1, z1 = Spring.GetUnitPosition(silo) - local x2, y2, z2 = Spring.GetUnitPosition(unit) - - if not x1 or not x2 then return 0 end - - if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end - - return 1 - end + getStockpile = siloMissileStockpile }, } @@ -613,7 +590,7 @@ local function shockley_controller_class() end local function inferno_controller_class() - local self = missle_class() + local self = missile_class() self.x = 570 self.y = 38 self.name = "napalmmissile" @@ -623,21 +600,7 @@ local function inferno_controller_class() [UnitDefNames["napalmmissile"].id] = { launchCmd = CMD.ATTACK, weaponId = 1, - getStockpile = function(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, y1, z1 = Spring.GetUnitPosition(silo) - local x2, y2, z2 = Spring.GetUnitPosition(unit) - - if not x1 or not x2 then return 0 end - - if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end - - return 1 - end + getStockpile = siloMissileStockpile }, } @@ -645,7 +608,7 @@ local function inferno_controller_class() end local function slow_missile_controller_class() - local self = missle_class() + local self = missile_class() self.x = 614 self.y = 38 self.name = "missileslow" @@ -655,21 +618,7 @@ local function slow_missile_controller_class() [UnitDefNames["missileslow"].id] = { launchCmd = CMD.ATTACK, weaponId = 1, - getStockpile = function(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, y1, z1 = Spring.GetUnitPosition(silo) - local x2, y2, z2 = Spring.GetUnitPosition(unit) - - if not x1 or not x2 then return 0 end - - if distance3(x1, y1, z1, x2, y2, z2) > 600 then return 0 end - - return 1 - end + getStockpile = siloMissileStockpile }, } @@ -677,7 +626,7 @@ local function slow_missile_controller_class() end local function reef_missile_controller_class() - local self = missle_class() + local self = missile_class() self.x = 394 self.y = 38 self.name = "shipcarrier" @@ -698,7 +647,7 @@ local function reef_missile_controller_class() end local function trinity_missile_controller_class() - local self = missle_class() + local self = missile_class() self.x = 350 self.y = 38 self.name = "staticnuke" From ca90b4d7fe0245498931c0c10e6fb2cd86b5dc9c Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 4 Jul 2026 11:49:09 -0500 Subject: [PATCH 31/31] Missile launch preview: share Attack AoE's exact impact simulation The launch preview approximated the starburst trajectory, so it could disagree with the Attack AoE widget's terrain-impact marker shown when the missile is selected and attacked directly. Expose gui_attack_aoe's vlaunch impact calculation as WG.AttackAoE.GetVlaunchImpact (extracting a shared BuildVlaunch helper, behavior-preserving) and have the launch preview call it, matching the fire-origin convention. Removes the widget's whole approximation and apex-calibration code (~110 lines); the two previews now share one code path. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/gui_attack_aoe.lua | 61 ++++++++-- LuaUI/Widgets/missile_command_center.lua | 141 +++-------------------- 2 files changed, 65 insertions(+), 137 deletions(-) diff --git a/LuaUI/Widgets/gui_attack_aoe.lua b/LuaUI/Widgets/gui_attack_aoe.lua index b27df4f10e..8fd0a35e00 100644 --- a/LuaUI/Widgets/gui_attack_aoe.lua +++ b/LuaUI/Widgets/gui_attack_aoe.lua @@ -215,6 +215,25 @@ end --initialization -------------------------------------------------------------------------------- +-- Vlaunch (starburst) flight parameters, derived purely from the weapon def. +-- Used by the AoE preview here and exposed via WG for the missile launch preview, +-- so both compute impact points from an identical trajectory model. +local function BuildVlaunch(weaponDef) + if (weaponDef.uptime or 0) <= 0 then + return nil + end + -- In the first frame the projectile moves startVelocity + 2*Acceleration + local startSpeed = math.min(weaponDef.startvelocity + weaponDef.weaponAcceleration, weaponDef.projectilespeed) + return { + upFrames = math.floor(weaponDef.uptime * 30 + 0.5) - 2, + accel = weaponDef.weaponAcceleration, + turnRate = weaponDef.turnRate, + startSpeed = startSpeed, + startHeight = startHeights[weaponDef.name] or 0, + endSpeed = weaponDef.projectilespeed, + } +end + local function getWeaponInfo(weaponDef, unitDef) local retData @@ -277,18 +296,7 @@ local function getWeaponInfo(weaponDef, unitDef) else retData.aoe = 0 end - if (weaponDef.uptime or 0) > 0 then - -- In the first frame the projectile moves startVelocity + 2*Acceleration - local startSpeed = math.min(weaponDef.startvelocity + weaponDef.weaponAcceleration, weaponDef.projectilespeed) - retData.vlaunch = { - upFrames = math.floor(weaponDef.uptime * 30 + 0.5) - 2, - accel = weaponDef.weaponAcceleration, - turnRate = weaponDef.turnRate, - startSpeed = startSpeed, - startHeight = startHeights[weaponDef.name] or 0, - endSpeed = weaponDef.projectilespeed, - } - end + retData.vlaunch = BuildVlaunch(weaponDef) retData.cost = cost retData.mobile = not unitDef.isImmobile retData.waterWeapon = waterWeapon @@ -939,6 +947,33 @@ local function CalculateVlaunchImpact(info, fx, fy, fz, tx, ty, tz) return false end +-------------------------------------------------------------------------------- +-- Shared vlaunch impact query (used by the missile launch preview widget) +-------------------------------------------------------------------------------- + +local vlaunchInfoCache = {} + +-- Returns the terrain impact point (x, y, z) of a vlaunch (starburst) shot fired +-- from (fx, fy, fz) at (tx, ty, tz), or nil if it reaches the target +-- unobstructed or the weapon is not a vlaunch weapon. +local function GetVlaunchImpact(weaponDefID, fx, fy, fz, tx, ty, tz) + local info = vlaunchInfoCache[weaponDefID] + if info == nil then + local wd = WeaponDefs[weaponDefID] + local vlaunch = wd and BuildVlaunch(wd) + info = (vlaunch and {vlaunch = vlaunch, range = wd.range}) or false + vlaunchInfoCache[weaponDefID] = info + end + if not info then + return nil + end + local hx, hy, hz = CalculateVlaunchImpact(info, fx, fy, fz, tx, ty, tz) + if hx then + return hx, hy, hz + end + return nil +end + -------------------------------------------------------------------------------- --Main draw -------------------------------------------------------------------------------- @@ -1027,10 +1062,12 @@ function widget:Initialize() aoeDefInfo[unitDefID], dgunInfo[unitDefID], extraDrawRangeDefInfo[unitDefID] = SetupUnit(unitDef) end SetupDisplayLists() + WG.AttackAoE = {GetVlaunchImpact = GetVlaunchImpact} end function widget:Shutdown() DeleteDisplayLists() + WG.AttackAoE = nil end function widget:DrawWorld() diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index 6fd9c97cf1..786d174334 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -46,27 +46,6 @@ local numAoECircles = 9 local aoeColor = {1, 0, 0, 1} local mouseDistance = 1000 local floor = math.floor -local sqrt = math.sqrt -local min = math.min - -local spGetGroundHeight = Spring.GetGroundHeight -local spGetProjectilePosition = Spring.GetProjectilePosition - --- Terrain-block prediction (starburst silo missiles only). A starburst goes --- straight up, turns, then dives in a straight line to the target. We model the --- dive as a line from the crest (apex height, part-way toward the target) down --- to the target, and sample terrain under it. Both the crest height and its --- horizontal distance from the launcher are learned from real shots, so the --- dive angle matches what the missile actually flies. -local SAMPLE_STEP = 24 -- elmos between terrain samples along the dive line -local BLOCK_EPSILON = 12 -- ground must exceed the line by this much to block -local TARGET_SKIP = 0.92 -- ignore the last bit of the dive; it comes down steeply -local APEX_SAFETY = 0.9 -- fraction of the observed crest height used by the model - --- weaponDefID -> {h = crest height above launch ground, d = crest distance from launcher} -local learnedApex = {} -local launchWeaponDefs = {} -- weaponDefID -> true (starburst launch weapons to learn from) -local flightTrack = {} -- proID -> {wdid, baseX, baseZ, baseY, peak, peakDist} local pulse_timmer = Spring.GetTimer() local function getPulse() @@ -163,50 +142,6 @@ local function distance(x1,z1,x2,z2) return (x1-x2)*(x1-x2)+(z1-z2)*(z1-z2) end --- Crest geometry (height above launch ground, horizontal distance from launcher) --- of the modelled trajectory. Learned from real shots once seen; until then a --- rough scale from range (missiles with more range lob higher and further). -local function getApexGeom(weaponDefID, weaponDef) - local learned = learnedApex[weaponDefID] - if learned then - return learned.h, learned.d - end - local range = weaponDef.range or 3000 - return range * 0.3, range * 0.12 -end - --- Walk the dive line from the crest (part-way toward the target, at crest --- height) down to the target, sampling terrain. Returns the first point where --- ground rises into the path (impactX, impactY, impactZ, true), or the target --- and false if it stays clear. -local function predictImpact(sx, sz, tx, ty, tz, apexH, crestDist) - local apexY = spGetGroundHeight(sx, sz) + apexH - local dx, dz = tx - sx, tz - sz - local dist = sqrt(dx*dx + dz*dz) - if dist < 1 then return tx, ty, tz, false end - -- The dive starts where the missile crests: crestDist toward the target, but - -- never past the near half of a short shot. - local dEff = min(crestDist, dist * 0.6) - local nx, nz = dx / dist, dz / dist - local startX, startZ = sx + nx * dEff, sz + nz * dEff - local diveDX, diveDZ = tx - startX, tz - startZ - local diveDist = sqrt(diveDX*diveDX + diveDZ*diveDZ) - if diveDist < 1 then return tx, ty, tz, false end - local steps = floor(diveDist / SAMPLE_STEP) - for i = 1, steps do - local f = i / steps - if f > TARGET_SKIP then break end - local px = startX + diveDX * f - local pz = startZ + diveDZ * f - local lineY = apexY + (ty - apexY) * f - local groundY = spGetGroundHeight(px, pz) - if groundY > lineY + BLOCK_EPSILON then - return px, groundY, pz, true - end - end - return tx, ty, tz, false -end - -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -467,9 +402,6 @@ local function missile_class() local unit = self:getPreferredUnit{x = mx, z = mz} if not unit then return end - local ux, uy, uz = Spring.GetUnitPosition(unit) - if not ux then return end - local unitDefID = Spring.GetUnitDefID(unit) if not unitDefID then return end @@ -479,6 +411,14 @@ local function missile_class() 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 @@ -489,13 +429,16 @@ local function missile_class() local range = weaponDef.range if dist > range * range then return end - -- For lobbed (starburst) missiles, relocate the impact to any terrain that - -- blocks the descent so the ring lands where the missile actually would. + -- Relocate the impact to any terrain that blocks the shot, using the same + -- trajectory model the Attack AoE widget uses when the missile is selected + -- directly, so the two previews always agree. local ix, iy, iz = mx, my, mz local blocked = false - if weaponDef.type == "StarburstLauncher" then - local apexH, crestDist = getApexGeom(weapon.weaponDef, weaponDef) - ix, iy, iz, blocked = predictImpact(ux, uz, mx, my, mz, apexH, crestDist) + if WG.AttackAoE and WG.AttackAoE.GetVlaunchImpact then + local hx, hy, hz = WG.AttackAoE.GetVlaunchImpact(weapon.weaponDef, ux, uy, uz, mx, my, mz) + if hx then + ix, iy, iz, blocked = hx, hy, hz, true + end end if blocked then @@ -691,17 +634,6 @@ for _, command in ipairs(orderedCommands) do command.iconTexture = unitDef and ("#" .. unitDef.id) or nil end --- Collect the launch weapons whose real trajectory we calibrate apex from. -for _, command in pairs(commands) do - for unitDefID, launchType in pairs(command.launchableTypes) do - local ud = UnitDefs[unitDefID] - local weapon = ud and ud.weapons and ud.weapons[launchType.weaponId] - if weapon and weapon.weaponDef and WeaponDefs[weapon.weaponDef].type == "StarburstLauncher" then - launchWeaponDefs[weapon.weaponDef] = true - end - end -end - local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 local wasEmptySelection = false @@ -783,44 +715,3 @@ function widget:DrawWorld() end end --------------------------------------------------------------------------------- --- Calibration: learn each starburst missile's real apex height from live shots. --------------------------------------------------------------------------------- - -function widget:ProjectileCreated(proID, proOwnerID, weaponDefID) - if not launchWeaponDefs[weaponDefID] then return end - local x, y, z = spGetProjectilePosition(proID) - if not x then return end - flightTrack[proID] = { - wdid = weaponDefID, baseX = x, baseZ = z, baseY = spGetGroundHeight(x, z), - peak = y, peakDist = 0, - } -end - -function widget:GameFrame() - if not next(flightTrack) then return end - for proID, data in pairs(flightTrack) do - local x, y, z = spGetProjectilePosition(proID) - if y then - if y > data.peak then - data.peak = y - local ddx, ddz = x - data.baseX, z - data.baseZ - data.peakDist = sqrt(ddx*ddx + ddz*ddz) - elseif y < data.peak - 40 then - -- Past the crest: record its height and horizontal distance, then stop. - local h = (data.peak - data.baseY) * APEX_SAFETY - if h > 0 then - learnedApex[data.wdid] = {h = h, d = data.peakDist} - end - flightTrack[proID] = nil - end - else - flightTrack[proID] = nil - end - end -end - -function widget:ProjectileDestroyed(proID) - flightTrack[proID] = nil -end -