From 77243b7cf6429ba752a826a28e9024153aaf675e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 00:33:53 +0000 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] =?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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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 - From 3836ec00f94a7ec1aca175903259400932d74f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 04:30:05 +0000 Subject: [PATCH 32/49] Missile menu: drop second tab row (topRow) for the Launch tab Per GoogleFrog's review, the always-visible second tab row was the main UI objection. Removing the topRow flag collapses the Launch tab into the normal single tab row instead of growing the menu taller. First step toward the hidden-tab-opened-by-a-launch-button approach. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Configs/integral_menu_config.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index f68b1cb1c5..cc5ca1c9ea 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -551,7 +551,6 @@ local commandPanels = { end, loiterable = true, alwaysShowTab = true, - topRow = true, buttonLayoutConfig = buttonLayoutConfig.command, badgeIconsWG = "missileActiveIcons", gridHotkeys = true, From b225774d3c7109d92c01ba8f1062a095e2610a90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 14:57:07 +0000 Subject: [PATCH 33/49] Integral menu: add hidden-tab reveal API (OpenTab/CloseHiddenTab) Adds the mechanism for a tab that stays out of the tab strip until explicitly opened, so the missiles Launch tab can be revealed by a core-selector launch button instead of standing permanently in the strip. - A panel flagged hiddenTab is skipped in the tab-collection loop unless its name matches revealHiddenTab. - WG.IntegralMenu.OpenTab(name)/CloseHiddenTab()/IsHiddenTabOpen() drive it. - Opening forces the tab strip visible and selects the tab; selecting any other tab clears the open state (SwitchToTab), matching "clicking any tab closes it". - If the revealed tab is no longer available (e.g. all missile units lost) the open state is cleared so it cannot get stuck. Purely additive: no panel sets hiddenTab yet, so behaviour is unchanged until the missiles tab is flipped to hidden in a later step. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Widgets/gui_chili_integral_menu.lua | 60 ++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index 7469f83e03..adb0be5276 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -139,6 +139,11 @@ local background local returnToOrdersCommand = false local simpleModeEnabled = true +-- Name of a hiddenTab panel (e.g. the missiles Launch tab) to reveal this cycle, +-- or false when no hidden tab is open. Hidden tabs are kept out of the tab strip +-- until something calls WG.IntegralMenu.OpenTab; selecting any other tab clears it. +local revealHiddenTab = false + local buildTabHolder, buttonsHolder -- Required for padding update setting local mainWindow, baseWindowHeight, buttonAreaHeight -- Required for growing the menu for a second tab row -------------------------------------------------------------------------------- @@ -2241,6 +2246,11 @@ local function GetTabPanel(parent, rows, columns) if not tabList then return end + -- Selecting any other tab closes an open hidden tab (the missiles Launch + -- tab), so clicking a tab dismisses it as expected. + if revealHiddenTab and name ~= revealHiddenTab then + revealHiddenTab = false + end currentTab = name for i = 1, #tabList do local data = tabList[i] @@ -2504,7 +2514,10 @@ local function ProcessAllCommands(commands, customCommands) local forceShowTabs = false for i = 1, #commandPanels do local data = commandPanels[i] - if data.commandCount ~= 0 then + -- A hiddenTab (the missiles Launch tab) stays out of the tab strip until it + -- is explicitly opened via WG.IntegralMenu.OpenTab, which sets revealHiddenTab. + local hidden = data.hiddenTab and (data.name ~= revealHiddenTab) + if data.commandCount ~= 0 and not hidden then tabsToShow[#tabsToShow + 1] = data.tabButton if data.alwaysShowTab then forceShowTabs = true @@ -2522,6 +2535,25 @@ local function ProcessAllCommands(commands, customCommands) end end end + + -- A freshly opened hidden tab takes selection priority and forces the strip + -- visible. If it is no longer available (e.g. all missile units were lost), + -- clear the open state so it does not get stuck. + if revealHiddenTab then + local revealPresent = false + for i = 1, #tabsToShow do + if tabsToShow[i].name == revealHiddenTab then + revealPresent = true + break + end + end + if revealPresent then + tabToSelect = revealHiddenTab + forceShowTabs = true + else + revealHiddenTab = false + end + end statePanel.holder:SetVisibility(statePanel.commandCount ~= 0) if statePanel.commandCount ~= 0 then @@ -2904,6 +2936,32 @@ function externalFunctions.UpdateCommands() ProcessAllCommands(commands, customCommands) end +-- Reveal a hiddenTab panel (e.g. the missiles "Launch" tab) and select it. The +-- tab is otherwise kept out of the tab strip; opening it forces the strip +-- visible. Selecting any other tab closes it again (see SwitchToTab). +function externalFunctions.OpenTab(tabName) + if not initialized then + return + end + revealHiddenTab = tabName + externalFunctions.UpdateCommands() +end + +function externalFunctions.CloseHiddenTab() + if not revealHiddenTab then + return + end + revealHiddenTab = false + externalFunctions.UpdateCommands() +end + +function externalFunctions.IsHiddenTabOpen(tabName) + if tabName then + return revealHiddenTab == tabName + end + return revealHiddenTab ~= false +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Widget Interface From ea710aba29f53f1eea8439f5a77d8235813f1ce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:30:39 +0000 Subject: [PATCH 34/49] Core selector: silo button opens the missiles Launch tab Selecting a missile silo via its core-selector button now also calls WG.IntegralMenu.OpenTab("missiles"), so the launch commands come up immediately instead of only selecting the unit. Scoped to staticmissilesilo via a small def set that is easy to extend to other launcher structures. Works whether the Launch tab is a standing tab (current) or a hidden tab (later step): OpenTab selects it in the first case and reveals it in the second. Needs in-game testing, particularly the selection/reveal timing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Widgets/gui_chili_core_selector.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 3a3823a112..73f462454c 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -128,6 +128,19 @@ for name in pairs(exceptionList) do end end +-- Factory-type launchers whose core-selector button opens the missiles "Launch" +-- tab (in the integral menu) on selection, instead of only selecting the unit. +-- Add other launcher structures here to give them the same behaviour. +local missileLauncherFactories = { + staticmissilesilo = true, +} +local missileLauncherFactoryDefs = {} +for name in pairs(missileLauncherFactories) do + if UnitDefNames[name] then + missileLauncherFactoryDefs[UnitDefNames[name].id] = true + end +end + local function SetButtonColorHighlight(opacity) buttonColorHighlight[4] = opacity end @@ -1058,6 +1071,11 @@ local function GetFactoryButton(parent, unitID, unitDefID, categoryOrder) local x, y, z = Spring.GetUnitPosition(unitID) SetCameraTarget(x, y, z) end + -- Selecting a missile silo also opens the "Launch" tab so its launch + -- commands are immediately available. + if missileLauncherFactoryDefs[unitDefID] and WG.IntegralMenu and WG.IntegralMenu.OpenTab then + WG.IntegralMenu.OpenTab("missiles") + end end local constructionDefID From 1680e397ae29b0ad4b80c29c2feeabb78e3138d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 02:07:25 +0000 Subject: [PATCH 35/49] Core selector: show missile count on the silo button The missile silo button now shows the stockpiled missile count as a bottom label, sourced from WG.missileActiveIcons (the same data as the Launch tab badge). Build progress is already shown by the existing factory logic (the silo's current missile construction). Count is currently aggregate across all active missile types; scope to the silo's own missiles if that reads better in-game. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Widgets/gui_chili_core_selector.lua | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 73f462454c..a301e35de4 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -1169,8 +1169,23 @@ local function GetFactoryButton(parent, unitID, unitDefID, categoryOrder) local udid, num = next(queue[i]) constructionCount = constructionCount + num end - + UpdateTooltip(constructionCount) + + -- Missile silos show the stockpiled missile count on the button; the build + -- progress bar above is already driven by the silo's current construction + -- (the missile it is building). + if missileLauncherFactoryDefs[unitDefID] then + local count = 0 + local icons = WG.missileActiveIcons + if icons then + for i = 1, #icons do + count = count + (icons[i].count or 0) + end + end + button.SetBottomLabel((count > 0) and tostring(count) or "") + end + return true end From 9a4566ec696ba1c986f980ac3f2125e087035415 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 03:12:05 +0000 Subject: [PATCH 36/49] Core selector: single launch button with a 2-column missile grid Replaces the earlier per-silo approach with one aggregate launch button: - missile_command_center: export each active missile's launch command id in WG.missileActiveIcons, plus WG.MissileCommandCenter.SetActiveLaunch(cmdID) to arm a launch command by id. - gui_chili_core_selector: a single "launch" button (modelled on the aggregate "cons" button) that shows every stockpiled/building missile type in a 2-column grid, each cell showing its own icon + count + build progress and arming that missile's launch command on click. The button appears while missiles exist and removes itself when there are none. GetNewButton now exposes its control so the grid can be parented onto it. First cut, needs in-game testing. Known gap: the button keeps the standard button size, so cells shrink as more missile types appear; growing the button past two rows still needs button-layout (UpdatePosition) integration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Widgets/gui_chili_core_selector.lua | 199 ++++++++++++++++++---- LuaUI/Widgets/missile_command_center.lua | 18 +- 2 files changed, 181 insertions(+), 36 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index a301e35de4..fd6a492301 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -113,8 +113,10 @@ local IDLE_CONS_HIGHLIGHT_TIME = 0.5 local CONSTRUCTOR_ORDER = 1 local COMMANDER_ORDER = 2 local FACTORY_ORDER = 3 +local LAUNCH_ORDER = 4 local CONSTRUCTOR_BUTTON_ID = "cons" +local LAUNCH_BUTTON_ID = "launch" local exceptionList = { staticrearm = true, @@ -128,19 +130,6 @@ for name in pairs(exceptionList) do end end --- Factory-type launchers whose core-selector button opens the missiles "Launch" --- tab (in the integral menu) on selection, instead of only selecting the unit. --- Add other launcher structures here to give them the same behaviour. -local missileLauncherFactories = { - staticmissilesilo = true, -} -local missileLauncherFactoryDefs = {} -for name in pairs(missileLauncherFactories) do - if UnitDefNames[name] then - missileLauncherFactoryDefs[UnitDefNames[name].id] = true - end -end - local function SetButtonColorHighlight(opacity) buttonColorHighlight[4] = opacity end @@ -851,7 +840,13 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i } local externalFunctions = {} - + + -- Exposes the button control and its image so specialised buttons (e.g. the + -- launch button) can parent extra content onto them. + function externalFunctions.GetButtonControl() + return button, image + end + -- Update attributes function externalFunctions.SetImage(newImageFile) image.file = newImageFile @@ -1071,11 +1066,6 @@ local function GetFactoryButton(parent, unitID, unitDefID, categoryOrder) local x, y, z = Spring.GetUnitPosition(unitID) SetCameraTarget(x, y, z) end - -- Selecting a missile silo also opens the "Launch" tab so its launch - -- commands are immediately available. - if missileLauncherFactoryDefs[unitDefID] and WG.IntegralMenu and WG.IntegralMenu.OpenTab then - WG.IntegralMenu.OpenTab("missiles") - end end local constructionDefID @@ -1171,21 +1161,6 @@ local function GetFactoryButton(parent, unitID, unitDefID, categoryOrder) end UpdateTooltip(constructionCount) - - -- Missile silos show the stockpiled missile count on the button; the build - -- progress bar above is already driven by the silo's current construction - -- (the missile it is building). - if missileLauncherFactoryDefs[unitDefID] then - local count = 0 - local icons = WG.missileActiveIcons - if icons then - for i = 1, #icons do - count = count + (icons[i].count or 0) - end - end - button.SetBottomLabel((count > 0) and tostring(count) or "") - end - return true end @@ -1443,7 +1418,154 @@ local function GetConstructorButton(parent) externalFunctions.UpdateButton(dt) externalFunctions.UpdateHotkey() - + + return externalFunctions +end + +------------------------------------------------------------------------------- +------------------------------------------------------------------------------- +-- Launch button +-- +-- A single aggregate button that shows every stockpiled or building missile +-- type in a 2-column grid. Each cell arms that missile's launch command via the +-- missile widget. Added while missiles exist and self-removes (UpdateButton +-- returns false) once there are none. +-- +-- NOTE: the button currently keeps the standard button size, so cells shrink as +-- more missile types appear. Growing the button height past two rows needs +-- integration with the button-layout sizing (UpdatePosition) and is not done +-- here yet. + +local LAUNCH_COLUMNS = 2 + +local function GetLaunchButton(parent) + local function OnClick(mouse) + -- Clicking the button background (not a cell) does nothing; the cells + -- issue the launch commands. + end + + local button = GetNewButton(parent, OnClick, LAUNCH_ORDER, 0, BUTTON_COLOR) + local buttonControl = button.GetButtonControl() + button.SetImageVisible(false) -- the missile grid replaces the single icon + + local cells = {} + local lastKey = false + + local function GetCell(i) + if cells[i] then + return cells[i] + end + local cell = {} + cell.button = Button:New { + parent = buttonControl, + caption = "", + padding = {0, 0, 0, 0}, + noFont = true, + backgroundColor = {1, 1, 1, 0}, + OnClick = { + function () + if cell.cmd and WG.MissileCommandCenter then + WG.MissileCommandCenter.SetActiveLaunch(cell.cmd) + end + end + }, + } + cell.image = Image:New { + parent = cell.button, + x = "5%", y = "5%", right = "5%", bottom = "5%", + keepAspect = false, + file = "", + } + cell.bar = Progressbar:New { + parent = cell.image, + x = "5%", y = "5%", right = "5%", bottom = "5%", + value = 0, max = 1, caption = false, noFont = true, + color = {0.7, 0.7, 0.4, 0.6}, + backgroundColor = {1, 1, 1, 0.01}, + } + cell.label = Label:New { + parent = cell.button, + right = 2, bottom = 0, + align = "right", valign = "bottom", + objectOverrideFont = WG.GetFont(12), + caption = "", + } + cells[i] = cell + return cell + end + + local externalFunctions = { + SetPosition = button.SetPosition, + MoveUp = button.MoveUp, + MoveDown = button.MoveDown, + GetOrder = button.GetOrder, + UpdatePosition = button.UpdatePosition, + } + + -- Keep the single base icon hidden; the grid is drawn from cell controls. + function externalFunctions.SetImageVisible() + end + + function externalFunctions.UpdateButton(dt) + local icons = WG.missileActiveIcons or {} + local n = #icons + if n == 0 then + -- No missiles: report inactive so the list handler removes the button. + return false + end + + local width = buttonControl.width or 0 + local height = buttonControl.height or 0 + + -- Rebuild only when the missile set, counts, progress or button size change. + local keyParts = {} + for i = 1, n do + keyParts[i] = icons[i].icon .. ":" .. icons[i].count .. ":" .. math.floor((icons[i].progress or 0) * 100) + end + local key = table.concat(keyParts, ",") .. "|" .. width .. "x" .. height + if key == lastKey then + return true + end + lastKey = key + + local cols = LAUNCH_COLUMNS + local rows = math.max(1, math.ceil(n / cols)) + local cw = width / cols + local ch = height / rows + + for i = 1, n do + local cell = GetCell(i) + local data = icons[i] + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + cell.button:SetPos(col * cw, row * ch, cw, ch) + cell.button:SetVisibility(true) + cell.cmd = data.cmd + cell.image.file = data.icon + cell.image:Invalidate() + if (data.progress or 0) > 0 then + cell.bar:SetValue(data.progress) + cell.bar:SetVisibility(true) + else + cell.bar:SetVisibility(false) + end + cell.label:SetCaption((data.count > 0) and tostring(data.count) or "") + end + for i = n + 1, #cells do + cells[i].button:SetVisibility(false) + end + return true + end + + function externalFunctions.UpdateHotkey() + end + + function externalFunctions.Destroy() + button.Destroy() + button = nil + end + + externalFunctions.UpdateButton(0) return externalFunctions end @@ -1990,6 +2112,13 @@ function widget:Update(dt) end buttonList.UpdateButtons(timer) + + -- Show a single launch button while there are missiles to launch. It removes + -- itself (UpdateButton returns false) once there are none. + if WG.missileActiveIcons and #WG.missileActiveIcons > 0 and not buttonList.GetButton(LAUNCH_BUTTON_ID) then + buttonList.AddButton(LAUNCH_BUTTON_ID, GetLaunchButton(buttonHolder)) + end + timer = 0 end diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index 786d174334..6447bb437e 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -634,6 +634,22 @@ for _, command in ipairs(orderedCommands) do command.iconTexture = unitDef and ("#" .. unitDef.id) or nil end +local commandByCmdID = {} +for _, command in pairs(commands) do + commandByCmdID[command.cmd] = command +end + +-- Exposed for the core selector launch button: arm a missile launch command by +-- its command id. Does nothing if none of that missile type are available. +WG.MissileCommandCenter = { + SetActiveLaunch = function(cmdID) + local command = commandByCmdID[cmdID] + if command then + command:action() + end + end, +} + local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 local wasEmptySelection = false @@ -664,7 +680,7 @@ function widget:Update(dt) -- 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} + activeIcons[#activeIcons + 1] = {icon = command.iconTexture, count = count, progress = buildProgress, cmd = command.cmd} end -- Count string shown on the button (e.g. "x3"), empty when none stockpiled. From 6a880c4194c070133c6e2e29fdc91f78b5b9b8c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 03:23:46 +0000 Subject: [PATCH 37/49] Core selector: launch button opens the launcher menu on click The per-cell clickable grid made each missile too small to hit. Instead the whole launch button now opens the launcher option menu (the integral-menu Launch tab, via WG.IntegralMenu.OpenTab), where the launch buttons are shown at full size. The 2-column grid on the button stays as a display-only preview (icon + count + build progress); cells are plain images parented to the button so clicking anywhere opens the menu. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Widgets/gui_chili_core_selector.lua | 41 ++++++++++------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index fd6a492301..e406fcbcc6 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -1440,8 +1440,11 @@ local LAUNCH_COLUMNS = 2 local function GetLaunchButton(parent) local function OnClick(mouse) - -- Clicking the button background (not a cell) does nothing; the cells - -- issue the launch commands. + -- Clicking anywhere on the button opens the launcher option menu, where + -- the launch buttons are shown at full size. + if WG.IntegralMenu and WG.IntegralMenu.OpenTab then + WG.IntegralMenu.OpenTab("missiles") + end end local button = GetNewButton(parent, OnClick, LAUNCH_ORDER, 0, BUTTON_COLOR) @@ -1451,28 +1454,16 @@ local function GetLaunchButton(parent) local cells = {} local lastKey = false + -- Cells are display only (icon + count + build progress); they are parented + -- directly to the button and do not handle clicks, so the whole button stays + -- clickable and opens the launcher menu. local function GetCell(i) if cells[i] then return cells[i] end local cell = {} - cell.button = Button:New { - parent = buttonControl, - caption = "", - padding = {0, 0, 0, 0}, - noFont = true, - backgroundColor = {1, 1, 1, 0}, - OnClick = { - function () - if cell.cmd and WG.MissileCommandCenter then - WG.MissileCommandCenter.SetActiveLaunch(cell.cmd) - end - end - }, - } cell.image = Image:New { - parent = cell.button, - x = "5%", y = "5%", right = "5%", bottom = "5%", + parent = buttonControl, keepAspect = false, file = "", } @@ -1484,8 +1475,7 @@ local function GetLaunchButton(parent) backgroundColor = {1, 1, 1, 0.01}, } cell.label = Label:New { - parent = cell.button, - right = 2, bottom = 0, + parent = buttonControl, align = "right", valign = "bottom", objectOverrideFont = WG.GetFont(12), caption = "", @@ -1538,9 +1528,9 @@ local function GetLaunchButton(parent) local data = icons[i] local col = (i - 1) % cols local row = math.floor((i - 1) / cols) - cell.button:SetPos(col * cw, row * ch, cw, ch) - cell.button:SetVisibility(true) - cell.cmd = data.cmd + local cx, cy = col * cw, row * ch + cell.image:SetPos(cx, cy, cw, ch) + cell.image:SetVisibility(true) cell.image.file = data.icon cell.image:Invalidate() if (data.progress or 0) > 0 then @@ -1549,10 +1539,13 @@ local function GetLaunchButton(parent) else cell.bar:SetVisibility(false) end + cell.label:SetPos(cx, cy, cw, ch) + cell.label:SetVisibility(true) cell.label:SetCaption((data.count > 0) and tostring(data.count) or "") end for i = n + 1, #cells do - cells[i].button:SetVisibility(false) + cells[i].image:SetVisibility(false) + cells[i].label:SetVisibility(false) end return true end From ed0733e762da287766129031ab205f81dd3d4944 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 04:32:10 +0000 Subject: [PATCH 38/49] Missile launch tab: make it a hidden tab, remove dead per-cell code - integral_menu_config: the Launch tab is now hiddenTab instead of alwaysShowTab, so it no longer stands in the tab strip (even when a unit is selected). It only appears when opened via the core-selector launch button (OpenTab). - integral menu: clear the revealed hidden tab on a selection change, so opening the launch menu and then selecting a unit returns to the unit's normal tabs instead of sticking on the missiles tab. - missile_command_center: remove the now-unused SetActiveLaunch export and the cmd field on missileActiveIcons (the launch button opens the menu rather than launching per cell). Needs in-game testing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Configs/integral_menu_config.lua | 2 +- LuaUI/Widgets/gui_chili_integral_menu.lua | 7 +++++++ LuaUI/Widgets/missile_command_center.lua | 18 +----------------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index cc5ca1c9ea..c4d65c6c6b 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -550,7 +550,7 @@ local commandPanels = { return pos ~= nil, pos end, loiterable = true, - alwaysShowTab = true, + hiddenTab = true, buttonLayoutConfig = buttonLayoutConfig.command, badgeIconsWG = "missileActiveIcons", gridHotkeys = true, diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index adb0be5276..e794aef407 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -2450,6 +2450,13 @@ local function ProcessAllCommands(commands, customCommands) local selectionChanged = (selectionSignature ~= lastSelectionSignature) lastSelectionSignature = selectionSignature + -- A selection change dismisses an opened hidden tab (the launch menu), so + -- selecting a unit returns to its normal command tabs rather than staying on + -- the revealed missiles tab. + if selectionChanged and revealHiddenTab then + revealHiddenTab = false + end + selectionIndex = selectionIndex + 1 for i = 1, #commandPanels do diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index 6447bb437e..786d174334 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -634,22 +634,6 @@ for _, command in ipairs(orderedCommands) do command.iconTexture = unitDef and ("#" .. unitDef.id) or nil end -local commandByCmdID = {} -for _, command in pairs(commands) do - commandByCmdID[command.cmd] = command -end - --- Exposed for the core selector launch button: arm a missile launch command by --- its command id. Does nothing if none of that missile type are available. -WG.MissileCommandCenter = { - SetActiveLaunch = function(cmdID) - local command = commandByCmdID[cmdID] - if command then - command:action() - end - end, -} - local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 local wasEmptySelection = false @@ -680,7 +664,7 @@ function widget:Update(dt) -- 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, cmd = command.cmd} + activeIcons[#activeIcons + 1] = {icon = command.iconTexture, count = count, progress = buildProgress} end -- Count string shown on the button (e.g. "x3"), empty when none stockpiled. From 899a89e0a024f77dc5e0950533fba981acd34d33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 04:57:28 +0000 Subject: [PATCH 39/49] Core selector: grow the launch button by half a button per grid row Implements variable-height growth for the launch button (option 2: real sizes rather than whole slots). Since the launch button is the last button, its growth only needs to (a) report a larger long-axis extent and (b) enlarge the background panel; no other button's offset is affected. - GetNewButton takes an optional getLongSize for its own extent; the offset of following buttons still uses the normal button size. - The launch button reports max(buttonSizeLong, ceil(n/2) * buttonSizeLong/2): two rows fit a normal button, each further row adds half a button length. - The background panel adds launchButtonExtraLong; a relayout runs when the row count changes (and resets to 0 when the button is removed). Needs in-game testing, especially the panel not overflowing the screen with many missile types, and horizontal-mode behaviour (there the button grows along its long axis, i.e. wider). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018Ljpwg32zkj1cdsWRjiL7m --- LuaUI/Widgets/gui_chili_core_selector.lua | 57 ++++++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index e406fcbcc6..5325e9e4b5 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -98,6 +98,12 @@ local myTeamID = Spring.GetMyTeamID() local buttonSizeShort = 4 local buttonCountLimit = 7 +-- Extra long-axis size (beyond one normal button) that the launch button needs +-- for its grid rows, and a flag to relayout when it changes. The launch button +-- is the last button, so its growth only has to enlarge the background panel. +local launchButtonExtraLong = 0 +local wantLaunchRelayout = false + ------------------------------------------------------------------------------- ------------------------------------------------------------------------------- @@ -702,6 +708,8 @@ local function GetBackground(parent) local buttons = math.min(buttonCountLimit, math.max(buttonCount, options.minButtonSpaces.value)) local size = buttons*options.buttonSizeLong.value + (buttons - 1)*options.buttonSpacing.value + -- Extra room for the launch button, which grows past one normal button. + size = size + launchButtonExtraLong if options.vertical.value then size = size + 2*options.vertPadding.value else @@ -800,7 +808,11 @@ end -------------------------------------------------------------------------------- -- Button Handling -local function GetNewButton(parent, onClick, category, index, backgroundColor, imageFile, imageFile2) +-- getLongSize (optional): returns the button's extent along the long axis. Only +-- affects this button's own size, not the offset of following buttons, so it is +-- only safe to grow the last button (the launch button). Defaults to the normal +-- button size. +local function GetNewButton(parent, onClick, category, index, backgroundColor, imageFile, imageFile2, getLongSize) local position = 1 local hotkeyLabel, buildProgress, repeatImage, healthBar, hotkeyText, bottomLabel @@ -985,6 +997,9 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i local vPad = (options.vertical.value and options.buttonSpacing.value) or 0 local index = position - 1 + -- Offset uses the normal button size (all preceding buttons are normal); + -- only this button's own extent may be larger, via getLongSize. + local longSize = (getLongSize and getLongSize()) or options.buttonSizeLong.value if options.vertical.value then button._relativeBounds.left = options.horPaddingLeft.value button._relativeBounds.right = options.horPaddingRight.value @@ -993,7 +1008,7 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i button._relativeBounds.bottom = index*(options.buttonSizeLong.value + options.buttonSpacing.value) + options.vertPadding.value button._relativeBounds.width = nil button._givenBounds.width = nil - button._relativeBounds.height = options.buttonSizeLong.value + button._relativeBounds.height = longSize button:UpdateClientArea() else button._relativeBounds.left = index*(options.buttonSizeLong.value + options.buttonSpacing.value) + options.horPaddingLeft.value @@ -1002,7 +1017,7 @@ local function GetNewButton(parent, onClick, category, index, backgroundColor, i button._relativeBounds.top = options.vertPadding.value button._givenBounds.top = options.vertPadding.value button._relativeBounds.bottom = options.vertPadding.value - button._relativeBounds.width = options.buttonSizeLong.value + button._relativeBounds.width = longSize button._relativeBounds.height = nil button._givenBounds.height = nil button:UpdateClientArea() @@ -1447,7 +1462,11 @@ local function GetLaunchButton(parent) end end - local button = GetNewButton(parent, OnClick, LAUNCH_ORDER, 0, BUTTON_COLOR) + local function GetLongSize() + return options.buttonSizeLong.value + launchButtonExtraLong + end + + local button = GetNewButton(parent, OnClick, LAUNCH_ORDER, 0, BUTTON_COLOR, nil, nil, GetLongSize) local buttonControl = button.GetButtonControl() button.SetImageVisible(false) -- the missile grid replaces the single icon @@ -1500,10 +1519,27 @@ local function GetLaunchButton(parent) local icons = WG.missileActiveIcons or {} local n = #icons if n == 0 then - -- No missiles: report inactive so the list handler removes the button. + -- No missiles: reset any growth and report inactive so the list handler + -- removes the button. + if launchButtonExtraLong ~= 0 then + launchButtonExtraLong = 0 + wantLaunchRelayout = true + end return false end + local cols = LAUNCH_COLUMNS + local rows = math.max(1, math.ceil(n / cols)) + + -- A normal button holds two rows (two columns); each further row adds half + -- a button length. Grow the button, and the panel, past two rows. + local desiredLong = math.max(options.buttonSizeLong.value, rows * options.buttonSizeLong.value / 2) + local extra = desiredLong - options.buttonSizeLong.value + if extra ~= launchButtonExtraLong then + launchButtonExtraLong = extra + wantLaunchRelayout = true + end + local width = buttonControl.width or 0 local height = buttonControl.height or 0 @@ -1518,8 +1554,6 @@ local function GetLaunchButton(parent) end lastKey = key - local cols = LAUNCH_COLUMNS - local rows = math.max(1, math.ceil(n / cols)) local cw = width / cols local ch = height / rows @@ -2112,6 +2146,15 @@ function widget:Update(dt) buttonList.AddButton(LAUNCH_BUTTON_ID, GetLaunchButton(buttonHolder)) end + -- The launch button changed its row count: resize the panel and relayout. + if wantLaunchRelayout then + wantLaunchRelayout = false + if mainBackground then + mainBackground.UpdateSize() + end + buttonList.UpdateLayout() + end + timer = 0 end From bf1506f36037c8a5c51e3c2071c187c691783422 Mon Sep 17 00:00:00 2001 From: GoogleFrog Date: Wed, 8 Jul 2026 17:48:57 +1000 Subject: [PATCH 40/49] I guess it is time for an AI policy. --- ai_policy.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 ai_policy.md diff --git a/ai_policy.md b/ai_policy.md new file mode 100644 index 0000000000..945081a1fb --- /dev/null +++ b/ai_policy.md @@ -0,0 +1,77 @@ +# AI Usage Policy + + + +## Disclosure requirements + +**AI usage must be explicitly disclosed.** +Contributors must clearly state: +- which AI tool(s) were used (for example: Claude Code, Cursor, Copilot) +- the extent to which the contribution was AI-assisted + +## Pull requests + +**Undisclosed AI usage in pull requests will result in closure.** + +If maintainers reasonably suspect undisclosed AI use, the pull request will be closed. Repeated incidents can result in a ban. + +The author of the PR must write the description in part to demonstrate their understanding of the chagne. + +**All AI-assisted code must be fully verified by a human contributor.** + +Contributors are responsible for ensuring: + +- the code has been run and tested +- the code behaves correctly in practice, not just in theory + +It is recommended to attach test artifacts, such as screenshots or recordings, that demonstrate your code working for each test step. + +## Issues and discussions + +**AI assistance is permitted in issues and discussions, with a strict human-in-the-loop requirement:** + +- AI-generated content must be reviewed and edited by a human before submission. +- Verbosity, noise, and speculative content must be removed. +- Contributors remain responsible for research, accuracy, and clarity. + +**Contributors retain responsibility.** + +AI may assist with explanations, summaries, or drafting, but must not replace understanding or judgement. Having an AI post for you will result in a request to stop, repeat incidents will result in a ban. + +Contributors should endeavour to understand and test any claims sourced from AI, when feasible. Untested claims must be flagged as such. + +## Prohibited content + +**AI-generated media is not permitted.** +This includes, but is not limited to: art, images, videos, and audio. +Only code is eligible for AI assistance, subject to the rules above. + +## Enforcement + +**Repeated or intentional misuse of AI tools may result in contributor bans.** +Maintainers may enforce this policy at their discretion. + +## Why is this important? + +Zero-K is maintained by humans. + +Every issue, discussion, and pull request is read and reviewed by maintainers who volunteer their time and expertise. Submitting low-effort, unverified, or poorly understood work shifts the burden of validation onto maintainers and is considered disrespectful of that effort. + +The primary behaviour this policy seeks to prevent is **Vibe Coding**, defined as the uncritical generation and submission of AI-produced code without sufficient understanding, verification, or accountability by the contributor. This policy exists to prevent wasted maintainer time and to ensure high standards of code quality and maintainability. + +This policy exists to protect maintainers, preserve code quality, and ensure that collaboration remains productive and sustainable. + +## Responsible AI usage is welcome + +The restrictions outlined above exist due to repeated misuse of AI by contributors who submit unverified, low-quality, or poorly understood work. The issue is not the tools themselves, but how they are applied. + +When used responsibly, transparently, and with proper human oversight, AI can be a valuable productivity aid. + +## Attribution + +This policy is adapted from the [Beyond All Reason](https://github.com/beyond-all-reason/teiserver/blob/main/AI_POLICY.md) adaptation of the AI usage policy published by the [Ghostty](https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md) organisation. +The Zero-K team acknowledges and appreciates the clarity and intent of the original texts. From 1c8eda95861d28edb1bdf7ddb7645a0a77cb0db7 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 8 Jul 2026 09:14:48 -0500 Subject: [PATCH 41/49] Integral menu: keep the Launch tab button hidden, drop the dead second tab row The missiles Launch tab now never appears in the tab strip, even while it is the selected/open tab: SetTabs skips laying out a hiddenTab's button but still DoClicks it, so its command grid shows with no tab button. The hiddenTab flag is propagated onto the tab button for this. Also removes the now-dead second-tab-row (topRow) machinery. No panel sets topRow anymore (it was dropped from the Launch tab), so topHolder, the two-row SetRowGeometry branch and its window-grow-by-one-row logic, the hasBottom scan, and the topRow references were all unreachable. Collapsed to a single tab holder and removed the unused baseWindowHeight. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/gui_chili_integral_menu.lua | 98 ++++------------------- 1 file changed, 14 insertions(+), 84 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index e794aef407..0535770123 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -145,7 +145,7 @@ local simpleModeEnabled = true local revealHiddenTab = false local buildTabHolder, buttonsHolder -- Required for padding update setting -local mainWindow, baseWindowHeight, buttonAreaHeight -- Required for growing the menu for a second tab row +local mainWindow, buttonAreaHeight -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Widget Options @@ -2177,10 +2177,8 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit end local function GetTabPanel(parent, rows, columns) - -- 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{ + -- A single horizontal row of tab buttons filling the tab area. + local tabHolder = StackPanel:New{ x = 0, y = "0%", right = 0, @@ -2192,48 +2190,6 @@ 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 @@ -2269,31 +2225,14 @@ local function GetTabPanel(parent, rows, columns) tabList[currentSelectedIndex].SetSelected(false) end tabList = newTabList - 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 + tabHolder:ClearChildren() - local topActive, bottomActive = false, false for i = 1, #tabList do - if showTabs then - if tabList[i].topRow and hasBottom then - topHolder:AddChild(tabList[i].button) - topActive = true - else - bottomHolder:AddChild(tabList[i].button) - bottomActive = true - end + -- A hiddenTab (the missiles Launch tab) is never placed in the tab strip, + -- even while it is the selected tab: its content shows but no tab button + -- appears. It still gets DoClick below so its panel is displayed. + if showTabs and not tabList[i].hiddenTab then + tabHolder:AddChild(tabList[i].button) tabList[i].SetHideHotkey(variableHide) tabList[i].SetHotkeyActive(hotkeysActive) end @@ -2301,7 +2240,6 @@ local function GetTabPanel(parent, rows, columns) tabList[i].DoClick() end end - SetRowGeometry(topActive, bottomActive) end function externalFunctions.ClearTabs() @@ -2309,9 +2247,7 @@ local function GetTabPanel(parent, rows, columns) externalFunctions.SwitchToTab() tabList = false currentSelectedIndex = false - topHolder:ClearChildren() - bottomHolder:ClearChildren() - SetRowGeometry(false, false) + tabHolder:ClearChildren() end end @@ -2534,11 +2470,7 @@ local function ProcessAllCommands(commands, customCommands) data.queue.ClearOldButtons(selectionIndex) end if (not tabToSelect) and data.tabButton.name == lastTabSelected then - -- 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 + tabToSelect = lastTabSelected end end end @@ -2607,10 +2539,8 @@ 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. + -- The command-button area is bottom-anchored with a fixed height. The tab + -- strip takes the remaining height/7, matching the original 100/7% tab strip. buttonAreaHeight = height * 6/7 UpdateRadarIconSizeString(options.radar_icon_size.value) @@ -2735,7 +2665,7 @@ local function InitializeControls() end data.tabButton = GetTabButton(tabPanel, commandHolder, data.name, data.humanName, hotkey, data.loiterable, OnTabSelect, {iconsWG = data.badgeIconsWG}) - data.tabButton.topRow = data.topRow + data.tabButton.hiddenTab = data.hiddenTab if data.gridHotkeys and ((not data.disableableKeys) or options.unitsHotkeys2.value) then data.buttons.ApplyGridHotkeys(gridMap, (gridCustomOverrides and gridCustomOverrides[data.name]) or {}) From f20aae1175163295815ed6caf183e7390a530b9d Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 8 Jul 2026 09:15:00 -0500 Subject: [PATCH 42/49] Core selector: fix launch grid cell sizing and resize timing Cell layout: a single missile now fills the whole button (1x1 over the full 2x2 space); two or more use the 2-column grid with a 2x2 minimum so a single row keeps its half-button cell size instead of stretching to the full button height. Resize timing: when the row count changes, UpdateButton now resizes the button and background panel inline (button.UpdatePosition + mainBackground.UpdateSize) before laying out the cells, instead of deferring to the post-UpdateButtons wantLaunchRelayout pass a frame later. Because Chili does not realize the control's width/height synchronously, the cells now use the computed long-axis size (desiredLong) rather than the still-stale buttonControl field, so the button growth and grid placement happen in the same frame with no lag. wantLaunchRelayout is now only used for the shrink when the button is removed. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/gui_chili_core_selector.lua | 51 +++++++++++++++++------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 5325e9e4b5..9f33a076e9 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -99,8 +99,10 @@ local buttonSizeShort = 4 local buttonCountLimit = 7 -- Extra long-axis size (beyond one normal button) that the launch button needs --- for its grid rows, and a flag to relayout when it changes. The launch button --- is the last button, so its growth only has to enlarge the background panel. +-- for its grid rows. The launch button is the last button, so its growth only +-- has to enlarge the background panel; while it exists it resizes itself and the +-- panel inline (see UpdateButton). wantLaunchRelayout defers the shrink for when +-- the button is removed (no missiles left) and can no longer relayout itself. local launchButtonExtraLong = 0 local wantLaunchRelayout = false @@ -1446,12 +1448,12 @@ end -- missile widget. Added while missiles exist and self-removes (UpdateButton -- returns false) once there are none. -- --- NOTE: the button currently keeps the standard button size, so cells shrink as --- more missile types appear. Growing the button height past two rows needs --- integration with the button-layout sizing (UpdatePosition) and is not done --- here yet. +-- The button holds a minimum 2x2 grid (one normal button size) and grows along +-- its long axis past that. A single missile fills the whole button; with two or +-- more, cells keep a fixed half-button size and are not stretched to fill. local LAUNCH_COLUMNS = 2 +local LAUNCH_ROWS_MIN = 2 local function GetLaunchButton(parent) local function OnClick(mouse) @@ -1528,7 +1530,10 @@ local function GetLaunchButton(parent) return false end - local cols = LAUNCH_COLUMNS + -- A single missile fills the whole button (a 1x1 grid over the full 2x2 + -- space); two or more use the 2-column grid with a 2x2 minimum. + local singleCell = (n == 1) + local cols = singleCell and 1 or LAUNCH_COLUMNS local rows = math.max(1, math.ceil(n / cols)) -- A normal button holds two rows (two columns); each further row adds half @@ -1537,11 +1542,29 @@ local function GetLaunchButton(parent) local extra = desiredLong - options.buttonSizeLong.value if extra ~= launchButtonExtraLong then launchButtonExtraLong = extra - wantLaunchRelayout = true + -- Resize the button and background panel right here, so the taller + -- button and the cell grid below update together in the same pass + -- instead of a frame apart (the button is the last one, so growing it + -- does not shift any other button's offset). + button.UpdatePosition() + if mainBackground then + mainBackground.UpdateSize() + end end - local width = buttonControl.width or 0 - local height = buttonControl.height or 0 + -- The long axis (height when vertical, width when horizontal) was just + -- changed via UpdatePosition, but the control's realized width/height only + -- catches up on a later Chili pass. Use the computed size for that axis so + -- the cells land at their final positions in the same frame the button + -- grows, with no lag; the short axis is fixed by padding and safe to read. + local width, height + if options.vertical.value then + width = buttonControl.width or 0 + height = desiredLong + else + width = desiredLong + height = buttonControl.height or 0 + end -- Rebuild only when the missile set, counts, progress or button size change. local keyParts = {} @@ -1554,8 +1577,11 @@ local function GetLaunchButton(parent) end lastKey = key + -- With two or more missiles, divide by the minimum grid (2x2) so a single + -- row keeps its half-button height instead of stretching. A single missile + -- (cols == 1) fills the whole button. local cw = width / cols - local ch = height / rows + local ch = height / (singleCell and 1 or math.max(LAUNCH_ROWS_MIN, rows)) for i = 1, n do local cell = GetCell(i) @@ -2146,7 +2172,8 @@ function widget:Update(dt) buttonList.AddButton(LAUNCH_BUTTON_ID, GetLaunchButton(buttonHolder)) end - -- The launch button changed its row count: resize the panel and relayout. + -- The launch button was removed (no missiles left): shrink the panel and + -- relayout. Growth while the button exists is handled inline in UpdateButton. if wantLaunchRelayout then wantLaunchRelayout = false if mainBackground then From 5d1bc8ef423c39c6c3d2bb7991930d5603802d07 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 8 Jul 2026 10:59:10 -0500 Subject: [PATCH 43/49] Core selector: fix launch count label z-order/jitter and scale it with the cell - The count label is now a child of the cell image (not a sibling under the button), so it always draws on top of the icon and progress bar. A small right/bottom inset with autosize off keeps the digits inside the cell. - Split cell layout from per-frame value updates: positions/icons are set only when the missile set or grid size changes (a new layoutKey), while the count text and progress bar update in place every frame. Previously the whole grid was repositioned on every progress tick, which re-laid the label and made the count jitter vertically as a missile built. - Scale the count font with the cell width: base size fits a standard 2-column cell, so the single-missile cell (which fills the whole button) gets a proportionally larger number. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/gui_chili_core_selector.lua | 76 ++++++++++++++--------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 9f33a076e9..40ddc6c1f9 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -1454,6 +1454,9 @@ end local LAUNCH_COLUMNS = 2 local LAUNCH_ROWS_MIN = 2 +-- Count label font size for a standard (2-column) cell; it scales up with the +-- cell when there are fewer columns (a single missile fills the whole button). +local LAUNCH_LABEL_FONT = 12 local function GetLaunchButton(parent) local function OnClick(mouse) @@ -1473,7 +1476,7 @@ local function GetLaunchButton(parent) button.SetImageVisible(false) -- the missile grid replaces the single icon local cells = {} - local lastKey = false + local lastLayoutKey = false -- Cells are display only (icon + count + build progress); they are parented -- directly to the button and do not handle clicks, so the whole button stays @@ -1495,10 +1498,15 @@ local function GetLaunchButton(parent) color = {0.7, 0.7, 0.4, 0.6}, backgroundColor = {1, 1, 1, 0.01}, } + -- Parented to the image (not the button) so the count always draws on top + -- of the icon and its progress bar. A small right/bottom inset keeps the + -- digits inside the cell instead of spilling over the corner. cell.label = Label:New { - parent = buttonControl, + parent = cell.image, + x = 0, y = 0, right = 3, bottom = 4, + autosize = false, align = "right", valign = "bottom", - objectOverrideFont = WG.GetFont(12), + objectOverrideFont = WG.GetFont(LAUNCH_LABEL_FONT), caption = "", } cells[i] = cell @@ -1566,47 +1574,59 @@ local function GetLaunchButton(parent) height = buttonControl.height or 0 end - -- Rebuild only when the missile set, counts, progress or button size change. - local keyParts = {} - for i = 1, n do - keyParts[i] = icons[i].icon .. ":" .. icons[i].count .. ":" .. math.floor((icons[i].progress or 0) * 100) - end - local key = table.concat(keyParts, ",") .. "|" .. width .. "x" .. height - if key == lastKey then - return true - end - lastKey = key - -- With two or more missiles, divide by the minimum grid (2x2) so a single -- row keeps its half-button height instead of stretching. A single missile -- (cols == 1) fills the whole button. local cw = width / cols local ch = height / (singleCell and 1 or math.max(LAUNCH_ROWS_MIN, rows)) + -- Reposition cells only when the missile set or grid size changes, not on + -- every count/progress tick. Re-running SetPos each frame re-lays out the + -- cell labels and makes the count jitter vertically while a missile builds. + local iconParts = {} for i = 1, n do - local cell = GetCell(i) + iconParts[i] = icons[i].icon + end + local layoutKey = table.concat(iconParts, ",") .. "|" .. cols .. "|" .. width .. "x" .. height + if layoutKey ~= lastLayoutKey then + lastLayoutKey = layoutKey + -- Scale the count font with the cell: the base size fits a standard + -- 2-column cell, so a wider cell (fewer columns) gets a bigger number. + local baseCellWidth = width / LAUNCH_COLUMNS + local labelFont = WG.GetFont(math.max(1, math.floor(LAUNCH_LABEL_FONT * cw / baseCellWidth + 0.5))) + for i = 1, n do + local cell = GetCell(i) + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local cx, cy = col * cw, row * ch + cell.image:SetPos(cx, cy, cw, ch) + cell.image:SetVisibility(true) + cell.image.file = icons[i].icon + cell.image:Invalidate() + cell.label.font = labelFont + cell.label.objectOverrideFont = labelFont + cell.label:Invalidate() + cell.label:SetVisibility(true) + end + for i = n + 1, #cells do + cells[i].image:SetVisibility(false) + cells[i].label:SetVisibility(false) + end + end + + -- Update the dynamic per-cell values every frame; these change the progress + -- bar fill and the count text in place without moving any cell. + for i = 1, n do + local cell = cells[i] local data = icons[i] - local col = (i - 1) % cols - local row = math.floor((i - 1) / cols) - local cx, cy = col * cw, row * ch - cell.image:SetPos(cx, cy, cw, ch) - cell.image:SetVisibility(true) - cell.image.file = data.icon - cell.image:Invalidate() if (data.progress or 0) > 0 then cell.bar:SetValue(data.progress) cell.bar:SetVisibility(true) else cell.bar:SetVisibility(false) end - cell.label:SetPos(cx, cy, cw, ch) - cell.label:SetVisibility(true) cell.label:SetCaption((data.count > 0) and tostring(data.count) or "") end - for i = n + 1, #cells do - cells[i].image:SetVisibility(false) - cells[i].label:SetVisibility(false) - end return true end From 4c60a8f2b0ac6f0764fb728bcf70e48c1d2383f3 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Wed, 8 Jul 2026 11:02:23 -0500 Subject: [PATCH 44/49] Launch button: appear promptly when a missile starts building Previously the launch button could lag up to ~0.5s behind a missile starting to build, because two independent 0.25s polls had to line up: the missile command center recomputing WG.missileActiveIcons, and the core selector reading it. - missile_command_center: refresh on the frame a relevant unit is created, finished or destroyed (via UnitCreated/UnitFinished/UnitDestroyed filtered to our team and the launchable/silo-missile unit defs), instead of waiting out the poll interval. A silo missile builds as a nanoframe of a launchable def, so UnitCreated fires the instant it starts. - core selector: check for adding the launch button every frame rather than only inside the throttled block, and populate it at once so it is not shown empty. Needs in-game testing. Co-Authored-By: Claude Opus 4.8 --- LuaUI/Widgets/gui_chili_core_selector.lua | 17 ++++++----- LuaUI/Widgets/missile_command_center.lua | 37 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 40ddc6c1f9..9069f04978 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -2179,18 +2179,21 @@ function widget:Update(dt) --debugIdleConsState() end + -- Add the launch button as soon as there are missiles to launch, every frame + -- rather than only in the throttled block below, so it appears promptly when + -- a missile starts building. Populate it at once so it is not shown empty. + if WG.missileActiveIcons and #WG.missileActiveIcons > 0 and not buttonList.GetButton(LAUNCH_BUTTON_ID) then + local launchButton = GetLaunchButton(buttonHolder) + buttonList.AddButton(LAUNCH_BUTTON_ID, launchButton) + launchButton.UpdateButton(dt) + end + timer = timer + dt if timer < UPDATE_FREQUENCY then return end - - buttonList.UpdateButtons(timer) - -- Show a single launch button while there are missiles to launch. It removes - -- itself (UpdateButton returns false) once there are none. - if WG.missileActiveIcons and #WG.missileActiveIcons > 0 and not buttonList.GetButton(LAUNCH_BUTTON_ID) then - buttonList.AddButton(LAUNCH_BUTTON_ID, GetLaunchButton(buttonHolder)) - end + buttonList.UpdateButtons(timer) -- The launch button was removed (no missiles left): shrink the panel and -- relayout. Growth while the button exists is handled inline in UpdateButton. diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index 786d174334..6c792ad8c4 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -634,6 +634,19 @@ for _, command in ipairs(orderedCommands) do command.iconTexture = unitDef and ("#" .. unitDef.id) or nil end +-- Unit defs whose creation/completion/destruction can change the launchable set +-- (the silo missiles and the units that hold them). Used to refresh immediately +-- on the relevant unit events instead of waiting for the next poll, so the +-- launch button appears promptly when a missile starts building. +local relevantUnitDefs = {} +for _, command in ipairs(orderedCommands) do + if command.launchableTypes then + for unitDefID in pairs(command.launchableTypes) do + relevantUnitDefs[unitDefID] = true + end + end +end + local UPDATE_FREQUENCY = 0.25 local timer = UPDATE_FREQUENCY + 1 local wasEmptySelection = false @@ -702,6 +715,30 @@ function widget:Update(dt) wasEmptySelection = emptySelection end +-- Run the next Update on the following frame rather than waiting out the poll +-- interval, so unit changes are reflected right away. +local function refreshSoon() + timer = UPDATE_FREQUENCY +end + +function widget:UnitCreated(unitID, unitDefID, unitTeam) + if unitTeam == Spring.GetMyTeamID() and relevantUnitDefs[unitDefID] then + refreshSoon() + end +end + +function widget:UnitFinished(unitID, unitDefID, unitTeam) + if unitTeam == Spring.GetMyTeamID() and relevantUnitDefs[unitDefID] then + refreshSoon() + end +end + +function widget:UnitDestroyed(unitID, unitDefID, unitTeam) + if unitTeam == Spring.GetMyTeamID() and relevantUnitDefs[unitDefID] then + refreshSoon() + end +end + function widget:CommandNotify(cmdID, cmdParams, cmdOptions) for _, command in pairs(commands) do if command:commandNotify(cmdID, cmdParams, cmdOptions) then return true end From 1daeda96160fa3384bb2b32b4700f56330560e36 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 11 Jul 2026 00:10:15 -0500 Subject: [PATCH 45/49] Missile launcher: launch hotkey, open-state highlight, capacity-safe build - Add a rebindable "Open missile launcher" hotkey (default Alt+L) registered like the factory-selection hotkeys; the launch button shows it in a corner only when a single cell leaves room, otherwise in the tooltip. - The launch button toggles the launcher and highlights orange (background and focus colour) while it is open, matching the integral menu's selected command. - Cap building at silo capacity: only build at a silo whose finished missiles plus queue are below capacity, and never queue past it, so a full silo no longer produces stuck, unusable missiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W2kBMfsLk5iRtfiA5uRKhG --- LuaUI/Widgets/gui_chili_core_selector.lua | 75 ++++++++++++++++--- LuaUI/Widgets/missile_command_center.lua | 88 ++++++++++++++--------- 2 files changed, 121 insertions(+), 42 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 702e61b5cf..88a8b07912 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -637,6 +637,44 @@ for i = 1, 16 do } end +-- Missile launcher hotkey, registered like the factory-selection hotkeys above. +-- Opens the launcher (same as clicking the Launch button in the selection bar). +local LAUNCH_HOTKEY_ACTION = "epic_chili_core_selector_launch" +-- Orange highlight matching the integral menu's selected-command colour, so the launch +-- button reads as "active" while the launcher is open, like an armed command button. +local LAUNCH_SELECTED_COLOR = {0.98, 0.48, 0.26, 0.85} + +-- Toggle the launcher: if it is already open, close it; otherwise open it and arm the +-- default missile. Shared by the launch button click and the launch hotkey. +local function OpenLauncher() + if not (WG.IntegralMenu and WG.IntegralMenu.OpenTab) then + return + end + if WG.IntegralMenu.IsHiddenTabOpen and WG.IntegralMenu.IsHiddenTabOpen("missiles") then + if WG.DismissLauncher then + WG.DismissLauncher() + elseif WG.IntegralMenu.CloseHiddenTab then + WG.IntegralMenu.CloseHiddenTab() + end + return + end + WG.IntegralMenu.OpenTab("missiles") + -- Arm a launch by default so the player can immediately click a target; + -- SelectDefaultMissile only picks a type that has a missile ready to fire. + if WG.SelectDefaultMissile then + WG.SelectDefaultMissile() + end +end +options_order[#options_order + 1] = "launch" +options["launch"] = { + name = "Open missile launcher", + desc = "Opens the missile launcher, same as clicking the Launch button in the selection bar.", + type = 'button', + hotkey = {key = 'L', mod = 'alt+'}, + path = 'Hotkeys/Selection', + OnChange = OpenLauncher, +} + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Helper Functions @@ -1477,17 +1515,9 @@ local LAUNCH_ROWS_MIN = 2 local LAUNCH_LABEL_FONT = 12 local function GetLaunchButton(parent) + -- Clicking anywhere on the button opens the launcher (shared with the launch hotkey). local function OnClick(mouse) - -- Clicking anywhere on the button opens the launcher option menu, where - -- the launch buttons are shown at full size. - if WG.IntegralMenu and WG.IntegralMenu.OpenTab then - WG.IntegralMenu.OpenTab("missiles") - end - -- Arm a launch by default so the player can immediately click a target. - -- SelectDefaultMissile only picks a type that has a missile ready to fire. - if WG.SelectDefaultMissile then - WG.SelectDefaultMissile() - end + OpenLauncher() end local function GetLongSize() @@ -1496,10 +1526,13 @@ local function GetLaunchButton(parent) local button = GetNewButton(parent, OnClick, LAUNCH_ORDER, 0, BUTTON_COLOR, nil, nil, GetLongSize) local buttonControl = button.GetButtonControl() + local defaultFocusColor = buttonControl.focusColor -- restore this when not highlighted button.SetImageVisible(false) -- the missile grid replaces the single icon local cells = {} local lastLayoutKey = false + local lastHotkey = false -- last hotkey string pushed to the label/tooltip + local lastLauncherOpen = nil -- last launcher-open state pushed to the background -- Cells are display only (icon + count + build progress); they are parented -- directly to the button and do not handle clicks, so the whole button stays @@ -1561,9 +1594,31 @@ local function GetLaunchButton(parent) return false end + -- Highlight the button while the launcher (missiles tab) is open, matching the + -- integral menu's selected-command colour, so it reads as active. + local launcherOpen = (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen + and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false + if launcherOpen ~= lastLauncherOpen then + lastLauncherOpen = launcherOpen + -- Set the focus (hover) colour too, or hovering the selected button paints + -- its default hover colour over the orange and hides the highlight. + buttonControl.focusColor = launcherOpen and LAUNCH_SELECTED_COLOR or defaultFocusColor + button.SetBackgroundColor(launcherOpen and LAUNCH_SELECTED_COLOR or BUTTON_COLOR) + end + -- A single missile fills the whole button (a 1x1 grid over the full 2x2 -- space); two or more use the 2-column grid with a 2x2 minimum. local singleCell = (n == 1) + + -- Show the hotkey in the corner only when a single cell leaves room for it + -- (one missile, or the silo placeholder); with a full grid it would cover a + -- cell, so it lives in the tooltip instead. The tooltip always carries it. + local hk = (WG.crude and WG.crude.GetHotkey(LAUNCH_HOTKEY_ACTION)) or '' + if hk ~= lastHotkey then + lastHotkey = hk + button.SetTooltip("Missile launcher" .. ((hk ~= '') and (" (" .. hk .. ")") or "")) + end + button.SetHotkey(singleCell and hk or '') local cols = singleCell and 1 or LAUNCH_COLUMNS local rows = math.max(1, math.ceil(n / cols)) diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index ef0b359892..688dd1ba83 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -737,21 +737,42 @@ local function getMyTeamSilos() return silos end --- Missiles physically occupying a silo's pads (built + building); capacity minus this --- is how many builds it can start at once. -local function siloUsedSlots(siloID, sx, sz) - local used = 0 +-- Missiles that have finished building on this silo's pads (build progress complete). +-- These occupy a pad and are not in the build queue any more. +local function siloFinishedCount(siloID, sx, sz) + local finished = 0 for _, mID in ipairs(Spring.GetUnitsInRectangle(sx - SILO_SEARCH, sz - SILO_SEARCH, sx + SILO_SEARCH, sz + SILO_SEARCH) or {}) do if Spring.GetUnitRulesParam(mID, "missile_parentSilo") == siloID then - used = used + 1 + local _, _, _, _, buildProgress = Spring.GetUnitHealth(mID) + if buildProgress and buildProgress >= 1 then + finished = finished + 1 + end + end + end + return finished +end + +-- Total missiles queued at a silo (the currently building one plus anything waiting). +local function siloBuildQueueLength(siloID) + local queue = Spring.GetFullBuildQueue(siloID) + local n = 0 + if queue then + for i = 1, #queue do + local block = queue[i] + if type(block) == "table" then + for _, count in pairs(block) do + n = n + count + end + end end end - return used + return n end --- Pick the silo that will build the next missile of this type: a silo with a free pad --- nearest the reference point (the Alt-click, else the screen centre) so it starts --- building at once, else the shortest build queue. Returns siloID, sx, sz (or nil). +-- Pick the nearest silo (to the Alt-click, else the screen centre) that still has spare +-- capacity: finished missiles on pads plus everything queued must be below the silo's +-- capacity. Returns siloID, sx, sz, or nil when every silo is full -- so a build is never +-- queued past capacity (which would produce a stuck, unusable missile). local function chooseBuildSilo(refX, refZ) local cx, cz = refX, refZ if not cx then @@ -760,28 +781,20 @@ local function chooseBuildSilo(refX, refZ) cx, cz = coords and coords[1], coords and coords[3] end - local bestFree, bestFreeD, bestFreeX, bestFreeZ - local bestQueue, bestQueueLen, bestQueueX, bestQueueZ + local bestSilo, bestD, bestX, bestZ for _, siloID in ipairs(getMyTeamSilos()) do local sx, _, sz = Spring.GetUnitPosition(siloID) if sx then - local free = SILO_CAPACITY - siloUsedSlots(siloID, sx, sz) - local d = cx and ((sx - cx) * (sx - cx) + (sz - cz) * (sz - cz)) or 0 - if free > 0 and (not bestFreeD or d < bestFreeD) then - bestFree, bestFreeD, bestFreeX, bestFreeZ = siloID, d, sx, sz - end - local queue = Spring.GetFullBuildQueue(siloID) - local qlen = queue and #queue or 0 - if not bestQueueLen or qlen < bestQueueLen then - bestQueue, bestQueueLen, bestQueueX, bestQueueZ = siloID, qlen, sx, sz + local committed = siloFinishedCount(siloID, sx, sz) + siloBuildQueueLength(siloID) + if committed < SILO_CAPACITY then + local d = cx and ((sx - cx) * (sx - cx) + (sz - cz) * (sz - cz)) or 0 + if not bestD or d < bestD then + bestSilo, bestD, bestX, bestZ = siloID, d, sx, sz + end end end end - - if bestFree then - return bestFree, bestFreeX, bestFreeZ - end - return bestQueue, bestQueueX, bestQueueZ + return bestSilo, bestX, bestZ end -- Build one missile of this type at a silo near (refX, refZ) -- the Alt-click point, @@ -1042,16 +1055,27 @@ function widget:SelectionChanged(selectedUnits) end end --- Right-click closes the launcher (the revealed missiles tab): stop the sticky re-arm --- and close the tab. Not consumed, so right-click still deselects any armed command and --- behaves normally otherwise. +-- Fully close the launcher: stop the sticky re-arm, drop any armed launch command, +-- and close the tab. Exposed so the core-selector launch button can toggle it closed. +local function dismissLauncher() + reArmCmd = false + local _, activeCmd = Spring.GetActiveCommand() + if activeCmd and commandByCmd[activeCmd] then + Spring.SetActiveCommand(nil) + end + if WG.IntegralMenu and WG.IntegralMenu.CloseHiddenTab then + WG.IntegralMenu.CloseHiddenTab() + end +end +WG.DismissLauncher = dismissLauncher + +-- Right-click while the launcher is open closes it (and consumes the click, so it acts +-- purely as "close the launcher" rather than also issuing an order). function widget:MousePress(mx, my, button) if button == 3 and WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen and WG.IntegralMenu.IsHiddenTabOpen("missiles") then - reArmCmd = false - if WG.IntegralMenu.CloseHiddenTab then - WG.IntegralMenu.CloseHiddenTab() - end + dismissLauncher() + return true end return false end From 3a2e59569f1eb5fc63a93c92ff80fe676f75a0c1 Mon Sep 17 00:00:00 2001 From: GoogleFrog Date: Sat, 11 Jul 2026 15:45:39 +1000 Subject: [PATCH 46/49] Revert seemingly irrelevant integral changes. --- LuaUI/Widgets/gui_chili_integral_menu.lua | 56 +++++++++++------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index e12c181ca5..6e895b194f 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -98,6 +98,7 @@ 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 = {} @@ -1974,7 +1975,7 @@ end local function GetTabButton(panel, contentControl, name, humanName, hotkey, loiterable, OnSelect) local disabled = disabledTabs[name] - + local function DoClick(mouse) if disabled or TabClickFunction(mouse) then return @@ -1985,7 +1986,7 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit OnSelect() end end - + local button = Button:New { classname = "button_tab", caption = humanName, @@ -1999,24 +2000,23 @@ 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, } - function externalFunctionsAndData.IsTabSelected() return contentControl.visible @@ -2082,12 +2082,11 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit end local function GetTabPanel(parent, rows, columns) - -- A single horizontal row of tab buttons filling the tab area. local tabHolder = StackPanel:New{ x = 0, - y = "0%", + y = 0, right = 0, - height = "100%", + bottom = 0, padding = {0, 0, 0, 0}, itemMargin = {0, 1, 0, -1}, parent = parent, @@ -2095,12 +2094,12 @@ local function GetTabPanel(parent, rows, columns) resizeItems = true, orientation = "horizontal", } - + local currentSelectedIndex local hotkeysActive = true local currentTab local tabList = false - + local externalFunctions = {} function externalFunctions.SwitchToTab(name) @@ -2142,7 +2141,6 @@ local function GetTabPanel(parent, rows, columns) end tabList = newTabList tabHolder:ClearChildren() - for i = 1, #tabList do -- A hiddenTab (the missiles Launch tab) is never placed in the tab strip, -- even while it is the selected tab: its content shows but no tab button @@ -2418,7 +2416,7 @@ local function ProcessAllCommands(commands, customCommands) if not tabToSelect then tabToSelect = "orders" end - + if #tabsToShow == 0 then tabPanel.ClearTabs() lastTabSelected = false @@ -2439,7 +2437,7 @@ local function ProcessAllCommands(commands, customCommands) 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 @@ -2455,15 +2453,11 @@ 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 - -- The command-button area is bottom-anchored with a fixed height. The tab - -- strip takes the remaining height/7, matching the original 100/7% tab strip. - buttonAreaHeight = height * 6/7 - UpdateRadarIconSizeString(options.radar_icon_size.value) - + gridKeyMap, gridMap, gridCustomOverrides = GenerateGridKeyMap(options.keyboardType2.value) - - mainWindow = Window:New{ + + local mainWindow = Window:New{ name = 'integralwindow', x = 0, bottom = 0, @@ -2488,27 +2482,27 @@ local function InitializeControls() x = options.leftPadding.value, y = "0%", right = options.rightPadding.value, - bottom = buttonAreaHeight, + height = "15%", padding = {2, 2, 2, -1}, parent = mainWindow, } - + tabPanel = GetTabPanel(buildTabHolder) - + buttonsHolder = Control:New{ x = options.leftPadding.value, - bottom = 0, + y = (100/7) .. "%", right = options.rightPadding.value, - height = buttonAreaHeight, + bottom = 0, padding = {0, 0, 0, 0}, parent = mainWindow, } - + background = Panel:New{ x = 0, - bottom = 0, + y = "15%", right = 0, - height = buttonAreaHeight, + bottom = 0, draggable = false, resizable = false, noFont = true, @@ -2550,6 +2544,8 @@ local function InitializeControls() local hotkey if data.optionName then hotkey = GetActionHotkey(EPIC_NAME .. data.optionName) + else + hotkey = GetActionHotkey(EPIC_NAME_UNITS) end if data.returnOnClick then From 82a87a9ea2d62e91044ea6fabdd296931fb59de9 Mon Sep 17 00:00:00 2001 From: GoogleFrog Date: Sat, 11 Jul 2026 15:48:02 +1000 Subject: [PATCH 47/49] More revert. --- LuaUI/Widgets/gui_chili_integral_menu.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index 6e895b194f..9b72174c52 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -146,7 +146,6 @@ local simpleModeEnabled = true local revealHiddenTab = false local buildTabHolder, buttonsHolder -- Required for padding update setting -local mainWindow, buttonAreaHeight -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Widget Options @@ -621,7 +620,6 @@ local buttonsByCommand = {} local alreadyRemovedTag = {} local lastRemovedTagResetFrame = false - -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Utility From 046d4bbe9ec27ecfa1d214403b74e1a9d5397774 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 11 Jul 2026 00:59:29 -0500 Subject: [PATCH 48/49] Missile launcher: register commands, keep selection after firing - Register the launch command ids (MISSILE_ZENITH..MISSILE_ZENO, 39610-39618) in the shared registry (customcmds.lua); missile_config.lua now reads them from Spring.Utilities.CMD, so the ids are reserved rather than raw numbers in ZK's range. - Re-apply the active command's button selection after the integral menu rebuilds its buttons, so an armed launch stays highlighted through firing/building. - Keep the launcher open after firing (drop returnOnClick on the missiles panel) and highlight the core-selector launch button while launch mode is active (armed command or tab open) via WG.IsLaunchActive. - Default-select Eos when the launcher is opened with a silo but no ready missiles. - Drop the descriptor "hidden" field (skip registering hidden controllers instead). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W2kBMfsLk5iRtfiA5uRKhG --- LuaRules/Configs/customcmds.lua | 11 ++++ LuaUI/Configs/integral_menu_config.lua | 4 +- LuaUI/Configs/missile_config.lua | 7 ++- LuaUI/Widgets/gui_chili_core_selector.lua | 28 +++++----- LuaUI/Widgets/gui_chili_integral_menu.lua | 8 +++ LuaUI/Widgets/missile_command_center.lua | 62 +++++++++++++++++------ 6 files changed, 89 insertions(+), 31 deletions(-) diff --git a/LuaRules/Configs/customcmds.lua b/LuaRules/Configs/customcmds.lua index 9538a4a60a..76539b3ddb 100644 --- a/LuaRules/Configs/customcmds.lua +++ b/LuaRules/Configs/customcmds.lua @@ -100,6 +100,17 @@ local commands = { FIELD_FAC_UNIT_TYPE = 38694, FIELD_FAC_QUEUELESS = 38695, + -- missile launcher (Missile Command Center widget; see Configs/missile_config.lua) + MISSILE_ZENITH = 39610, + MISSILE_TRINITY = 39611, + MISSILE_REEF = 39612, + MISSILE_SCYLLA = 39613, + MISSILE_EOS = 39614, + MISSILE_SEISMIC = 39615, + MISSILE_SHOCKLEY = 39616, + MISSILE_INFERNO = 39617, + MISSILE_ZENO = 39618, + -- terraform RAMP = 39734, LEVEL = 39736, diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 05d8d52909..d0e53bb021 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -545,7 +545,9 @@ local commandPanels = { hiddenTab = true, buttonLayoutConfig = buttonLayoutConfig.command, gridHotkeys = true, - returnOnClick = "orders", + -- No returnOnClick: firing should not close the launcher, so multiple missiles + -- can be fired in a row. It is dismissed explicitly (right-click / select a + -- unit / toggle the launch button). }, { humanName = "Orders", diff --git a/LuaUI/Configs/missile_config.lua b/LuaUI/Configs/missile_config.lua index 4415ff2288..d250824485 100644 --- a/LuaUI/Configs/missile_config.lua +++ b/LuaUI/Configs/missile_config.lua @@ -73,8 +73,13 @@ local missiles = { }, } +-- Command ids come from the shared registry (LuaRules/Configs/customcmds.lua) so they +-- are reserved ZK commands, keyed MISSILE_. Fall back to the sequential range if the +-- registry is not available (both resolve to the same 39610.. block). +local registeredCmds = Spring.Utilities and Spring.Utilities.CMD for i = 1, #missiles do - missiles[i].cmd = CMD_BASE + i - 1 + local m = missiles[i] + m.cmd = (registeredCmds and registeredCmds["MISSILE_" .. string.upper(m.key)]) or (CMD_BASE + i - 1) end return missiles diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 88a8b07912..0fbb2f3794 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -1531,8 +1531,8 @@ local function GetLaunchButton(parent) local cells = {} local lastLayoutKey = false - local lastHotkey = false -- last hotkey string pushed to the label/tooltip - local lastLauncherOpen = nil -- last launcher-open state pushed to the background + local lastHotkey = false -- last hotkey string pushed to the label/tooltip + local lastLauncherActive = nil -- last launch-active state pushed to the focus colour -- Cells are display only (icon + count + build progress); they are parented -- directly to the button and do not handle clicks, so the whole button stays @@ -1594,18 +1594,6 @@ local function GetLaunchButton(parent) return false end - -- Highlight the button while the launcher (missiles tab) is open, matching the - -- integral menu's selected-command colour, so it reads as active. - local launcherOpen = (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen - and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false - if launcherOpen ~= lastLauncherOpen then - lastLauncherOpen = launcherOpen - -- Set the focus (hover) colour too, or hovering the selected button paints - -- its default hover colour over the orange and hides the highlight. - buttonControl.focusColor = launcherOpen and LAUNCH_SELECTED_COLOR or defaultFocusColor - button.SetBackgroundColor(launcherOpen and LAUNCH_SELECTED_COLOR or BUTTON_COLOR) - end - -- A single missile fills the whole button (a 1x1 grid over the full 2x2 -- space); two or more use the 2-column grid with a 2x2 minimum. local singleCell = (n == 1) @@ -1708,6 +1696,18 @@ local function GetLaunchButton(parent) end cell.label:SetCaption((data.count > 0) and tostring(data.count) or "") end + + -- Highlight the button (background + focus/hover colour) while launch mode is + -- active -- launcher tab open, or a launch command still armed after firing -- + -- matching the integral menu's selected-command colour. Applied last (after any + -- resize/relayout above, which reset the colour) and every update, so the + -- highlight is not lost when the missile count changes. + local launcherActive = (WG.IsLaunchActive and WG.IsLaunchActive()) or false + if launcherActive ~= lastLauncherActive then + lastLauncherActive = launcherActive + buttonControl.focusColor = launcherActive and LAUNCH_SELECTED_COLOR or defaultFocusColor + end + button.SetBackgroundColor(launcherActive and LAUNCH_SELECTED_COLOR or BUTTON_COLOR) return true end diff --git a/LuaUI/Widgets/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index e12c181ca5..f0771914a8 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -2442,6 +2442,14 @@ local function ProcessAllCommands(commands, customCommands) -- Keeps main window for tweak mode.SetIntegralVisibility(visible) SetIntegralVisibility(not (#tabsToShow == 0 and selectedUnitCount == 0)) + + -- The buttons were just rebuilt as unselected. UpdateButtonSelection only reacts to + -- the active command *changing*, so with the same command still armed (e.g. a sticky + -- missile launch after firing) it would leave the rebuilt button unselected. Force + -- the active command's button to re-highlight here. + lastCmdID = nil + local _, activeCmdID = spGetActiveCommand() + UpdateButtonSelection(activeCmdID) end -------------------------------------------------------------------------------- diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index 688dd1ba83..2f30406a67 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -367,6 +367,11 @@ local function missile_class() end function self:commandsChanged() + -- A hidden controller (Scylla while combined with Eos) is simply not registered, + -- so it has no button. Registering a descriptor with a "hidden" field instead + -- makes the engine log "GetLuaCmdDescList() bad entry". + if self.hidden then return end + local customCommands = widgetHandler.customCommands -- All fields must be present and valid, or the engine logs @@ -381,7 +386,6 @@ local function missile_class() texture = "LuaUI/Images/commands/Bold/missile.png", tooltip = "Launch missile.", disabled = self.disabled or false, - hidden = self.hidden or false, params = {}, } end @@ -659,20 +663,6 @@ for _, cfg in ipairs(missileConfig) do orderedCommands[#orderedCommands + 1] = controller end --- Arm the launch command of the first ready missile type, in badge order (used by --- the core selector's launch button so a click immediately readies a shot). Only a --- type with something ready to fire is picked; if nothing is ready, nothing is armed --- (action() also guards on getCount, so a not-ready type is never selected). -WG.SelectDefaultMissile = function() - for _, command in ipairs(orderedCommands) do - if command:getCount() > 0 then - command:action() - return true - end - end - return false -end - -- Unit defs whose creation/completion/destruction can change the launchable set -- (the silo missiles and the units that hold them). Used to refresh immediately -- on the relevant unit events instead of waiting for the next poll, so the @@ -737,6 +727,48 @@ local function getMyTeamSilos() return silos end +-- Arm a command as the active launch (bypasses action()'s ready-count guard, so a +-- silo-built type can be selected with nothing ready yet in order to build). +local function armCommand(command) + local cmdIndex = Spring.GetCmdDescIndex(command.cmd) + if cmdIndex then + local alt, ctrl, meta, shift = Spring.GetModKeyState() + Spring.SetActiveCommand(cmdIndex, 1, true, false, alt, ctrl, meta, shift) + end +end + +-- Arm a default missile when the launcher is opened (used by the core selector's launch +-- button). Prefer the first type with a missile ready; otherwise, if a silo exists, arm +-- the first silo-built type (Eos) so it is selected by default and ready to build. +WG.SelectDefaultMissile = function() + for _, command in ipairs(orderedCommands) do + if command:getCount() > 0 then + armCommand(command) + return true + end + end + if #getMyTeamSilos() > 0 then + for _, command in ipairs(orderedCommands) do + if command.siloBuilt then + armCommand(command) + return true + end + end + end + return false +end + +-- True while launch mode is active: a launch command is armed, or the launcher tab is +-- open. The core selector uses this to keep the launch button highlighted after firing +-- (the command stays armed via the sticky re-arm even once the tab has closed). +WG.IsLaunchActive = function() + local _, activeCmd = Spring.GetActiveCommand() + if activeCmd and commandByCmd[activeCmd] then + return true + end + return (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false +end + -- Missiles that have finished building on this silo's pads (build progress complete). -- These occupy a pad and are not in the build queue any more. local function siloFinishedCount(siloID, sx, sz) From 3d6ee9e995cdb4e9aec913d81a8587be34c93d04 Mon Sep 17 00:00:00 2001 From: Amnykon Date: Sat, 11 Jul 2026 01:47:53 -0500 Subject: [PATCH 49/49] Missile launcher: deselect on tab close, launch-button polish - Deselect any armed launch command when the launcher tab closes (switching tabs, selecting a unit, right-click, toggling the button), so launch mode no longer lingers on the cursor after the launcher is dismissed. Highlight tracks the tab. - Launch button: inset the silo placeholder to match factory buttons' construction border; a single missile's count now matches the idle-con count (size, position, shadow); the highlight is re-applied after any resize so it is not lost on a count change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W2kBMfsLk5iRtfiA5uRKhG --- LuaUI/Widgets/gui_chili_core_selector.lua | 27 ++++++++++++++++------- LuaUI/Widgets/missile_command_center.lua | 26 ++++++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/LuaUI/Widgets/gui_chili_core_selector.lua b/LuaUI/Widgets/gui_chili_core_selector.lua index 0fbb2f3794..802d9ae436 100644 --- a/LuaUI/Widgets/gui_chili_core_selector.lua +++ b/LuaUI/Widgets/gui_chili_core_selector.lua @@ -1554,15 +1554,17 @@ local function GetLaunchButton(parent) color = {0.7, 0.7, 0.4, 0.6}, backgroundColor = {1, 1, 1, 0.01}, } - -- Parented to the image (not the button) so the count always draws on top - -- of the icon and its progress bar. A small right/bottom inset keeps the - -- digits inside the cell instead of spilling over the corner. + -- Parented to the image (not the button) so the count always draws on top of + -- the icon and its progress bar. Position and shadow match the standard bottom + -- count label (SetBottomLabel, used by the idle-con/factory buttons) so a single + -- missile's count reads identically. cell.label = Label:New { parent = cell.image, - x = 0, y = 0, right = 3, bottom = 7, + x = 0, y = 0, right = 5, bottom = 5, autosize = false, align = "right", valign = "bottom", objectOverrideFont = WG.GetFont(LAUNCH_LABEL_FONT), + fontShadow = true, caption = "", } cells[i] = cell @@ -1656,16 +1658,25 @@ local function GetLaunchButton(parent) local layoutKey = table.concat(iconParts, ",") .. "|" .. cols .. "|" .. width .. "x" .. height if layoutKey ~= lastLayoutKey then lastLayoutKey = layoutKey - -- Scale the count font with the cell: the base size fits a standard - -- 2-column cell, so a wider cell (fewer columns) gets a bigger number. + -- Count font: a single cell fills the whole button, so use the standard + -- idle-con / factory count size (14) rather than scaling up. Multiple cells + -- keep the per-cell scaled size (a wider cell gets a bigger number). local baseCellWidth = width / LAUNCH_COLUMNS - local labelFont = WG.GetFont(math.max(1, math.floor(LAUNCH_LABEL_FONT * cw / baseCellWidth + 0.5))) + local labelFont = WG.GetFont(singleCell and 14 + or math.max(1, math.floor(LAUNCH_LABEL_FONT * cw / baseCellWidth + 0.5))) for i = 1, n do local cell = GetCell(i) local col = (i - 1) % cols local row = math.floor((i - 1) / cols) local cx, cy = col * cw, row * ch - cell.image:SetPos(cx, cy, cw, ch) + if icons[i].isSilo then + -- Inset the silo placeholder to match the factory buttons' icon + + -- construction border (5% inset), so the border is not oversized. + local ix, iy = cw * 0.05, ch * 0.05 + cell.image:SetPos(cx + ix, cy + iy, cw - 2 * ix, ch - 2 * iy) + else + cell.image:SetPos(cx, cy, cw, ch) + end cell.image:SetVisibility(true) cell.image.file = icons[i].icon -- Silo placeholder (no missiles yet) gets the factory construction diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua index 2f30406a67..33301e8464 100644 --- a/LuaUI/Widgets/missile_command_center.lua +++ b/LuaUI/Widgets/missile_command_center.lua @@ -696,6 +696,7 @@ for _, command in pairs(commands) do end local wasShift = false local prevActiveMissileCommand = false +local wasTabOpen = false -- launcher tab open state, to deselect the command when it closes -- Tracks the Combine Eos and Scylla option so launchableTypes and the Scylla button's -- visibility are rebuilt only when it changes (see refreshCombine in Update). @@ -758,14 +759,12 @@ WG.SelectDefaultMissile = function() return false end --- True while launch mode is active: a launch command is armed, or the launcher tab is --- open. The core selector uses this to keep the launch button highlighted after firing --- (the command stays armed via the sticky re-arm even once the tab has closed). +-- True while the launcher is open (its hidden tab is revealed). The core selector uses +-- this to highlight the launch button. The tab stays open through firing (no +-- returnOnClick) and closes when the launcher is dismissed -- switching tabs, selecting +-- a unit, right-click, or toggling the button -- so this alone tracks "launcher active" +-- without falsely staying true when an armed command lingers after a dismissal. WG.IsLaunchActive = function() - local _, activeCmd = Spring.GetActiveCommand() - if activeCmd and commandByCmd[activeCmd] then - return true - end return (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false end @@ -955,6 +954,19 @@ function widget:Update(dt) prevActiveMissileCommand = activeMissileCommand wasShift = shift + -- When the launcher tab closes (switching tabs, selecting a unit, right-click, + -- toggling the button), deselect any armed launch command too, so launch mode does + -- not linger with the launcher shut. Catches every close path in one place. + local tabOpen = (WG.IntegralMenu and WG.IntegralMenu.IsHiddenTabOpen + and WG.IntegralMenu.IsHiddenTabOpen("missiles")) or false + if wasTabOpen and not tabOpen then + reArmCmd = false + if activeMissileCommand then + Spring.SetActiveCommand(nil) + end + end + wasTabOpen = tabOpen + timer = timer + dt if timer < UPDATE_FREQUENCY then return