diff --git a/LuaUI/Configs/integral_menu_config.lua b/LuaUI/Configs/integral_menu_config.lua index 0b2fdc1cf1..f68b1cb1c5 100644 --- a/LuaUI/Configs/integral_menu_config.lua +++ b/LuaUI/Configs/integral_menu_config.lua @@ -1,5 +1,27 @@ 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 = 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) + return missileCmdPos[cmdID] ~= nil +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Tooltips @@ -483,7 +505,58 @@ local factoryButtonLayoutOverride = { } } +for _, missile in ipairs(missileCmds) do + local unitDef = UnitDefNames[missile.icon] + local icon = unitDef and ("#" .. unitDef.id) or (imageDir .. 'Bold/attack.png') + commandDisplayConfig[missile.id] = { + texture = icon, + tooltip = missile.tooltip, + drawName = true, -- show the stockpile count / build progress string (set by the missile widget) + } +end + +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 = "Launch", + name = "missiles", + inclusionFunction = function(cmdID) + if not hasMissileUnits() then return false end + local pos = missileCmdPos[cmdID] + return pos ~= nil, pos + end, + loiterable = true, + alwaysShowTab = true, + topRow = true, + buttonLayoutConfig = buttonLayoutConfig.command, + badgeIconsWG = "missileActiveIcons", + gridHotkeys = true, + returnOnClick = "orders", + }, { humanName = "Orders", name = "orders", @@ -491,7 +564,8 @@ local commandPanels = { return ((cmdID >= 0 or unitMobilePanelSize == 1) and not buildCmdEconomy[cmdID] and not buildCmdFactory[cmdID] and not buildCmdSpecial[cmdID] and not buildCmdDefence[cmdID] and - not plateCommandID[cmdID]) + not plateCommandID[cmdID] and + not isMissileCommand(cmdID)) end, loiterable = true, buttonLayoutConfig = buttonLayoutConfig.command, diff --git a/LuaUI/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/gui_chili_integral_menu.lua b/LuaUI/Widgets/gui_chili_integral_menu.lua index 8265780711..7469f83e03 100644 --- a/LuaUI/Widgets/gui_chili_integral_menu.lua +++ b/LuaUI/Widgets/gui_chili_integral_menu.lua @@ -98,7 +98,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 = {} @@ -125,14 +124,23 @@ end local commandPanels, commandPanelMap, commandDisplayConfig, hiddenCommands, textConfig, buttonLayoutConfig, instantCommands, cmdPosDef = include("Configs/integral_menu_config.lua") +-- Commands whose displayConfig requests it draw their command.name (count / progress string) like stockpile. +for cmdID, displayConfig in pairs(commandDisplayConfig) do + if displayConfig.drawName then + DRAW_NAME_COMMANDS[cmdID] = true + end +end + local statePanel = {} local tabPanel local selectionIndex = 0 +local lastSelectionSignature = false -- to detect selection changes for tab defaulting local background local returnToOrdersCommand = false local simpleModeEnabled = true local buildTabHolder, buttonsHolder -- Required for padding update setting +local mainWindow, baseWindowHeight, buttonAreaHeight -- Required for growing the menu for a second tab row -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Widget Options @@ -607,6 +615,7 @@ local buttonsByCommand = {} local alreadyRemovedTag = {} local lastRemovedTagResetFrame = false + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Utility @@ -650,7 +659,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 @@ -1956,9 +1967,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 @@ -1969,7 +1980,7 @@ local function GetTabButton(panel, contentControl, name, humanName, hotkey, loit OnSelect() end end - + local button = Button:New { classname = "button_tab", caption = humanName, @@ -1983,23 +1994,119 @@ 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, } + + -- 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 + + 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 + end function externalFunctionsAndData.IsTabSelected() return contentControl.visible @@ -2065,11 +2172,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, @@ -2077,12 +2187,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) @@ -2107,10 +2259,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 @@ -2118,6 +2291,7 @@ local function GetTabPanel(parent, rows, columns) tabList[i].DoClick() end end + SetRowGeometry(topActive, bottomActive) end function externalFunctions.ClearTabs() @@ -2125,7 +2299,9 @@ local function GetTabPanel(parent, rows, columns) externalFunctions.SwitchToTab() tabList = false currentSelectedIndex = false - tabHolder:ClearChildren() + topHolder:ClearChildren() + bottomHolder:ClearChildren() + SetRowGeometry(false, false) end end @@ -2258,6 +2434,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 @@ -2319,16 +2501,24 @@ 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) 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 @@ -2341,15 +2531,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 @@ -2365,11 +2568,17 @@ 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 + UpdateRadarIconSizeString(options.radar_icon_size.value) - + gridKeyMap, gridMap, gridCustomOverrides = GenerateGridKeyMap(options.keyboardType2.value) - - local mainWindow = Window:New{ + + mainWindow = Window:New{ name = 'integralwindow', x = 0, bottom = 0, @@ -2394,27 +2603,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, @@ -2428,7 +2637,9 @@ local function InitializeControls() local function ReturnToOrders(cmdID) if options.selectionClosesTabOnSelect.value then - if commandPanelMap.orders then + -- Only return to orders if it is actually present; otherwise (e.g. + -- missiles tab with nothing selected) stay on the current tab. + if commandPanelMap.orders and commandPanelMap.orders.tabButton.IsTabPresent() then commandPanelMap.orders.tabButton.DoClick() end elseif options.selectionClosesTab.value and cmdID then @@ -2448,11 +2659,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 @@ -2483,8 +2695,9 @@ 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, {iconsWG = data.badgeIconsWG}) + 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 @@ -2647,6 +2860,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 @@ -2688,6 +2912,17 @@ function widget:Update() local _,cmdID = spGetActiveCommand() UpdateButtonSelection(cmdID) UpdateReturnToOrders(cmdID) + + -- 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] + + -- 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 function widget:KeyPress(key, modifier, isRepeat) diff --git a/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua b/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua index dbd9d7d702..783597344c 100644 --- a/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua +++ b/LuaUI/Widgets/gui_chili_selections_and_cursortip.lua @@ -2150,7 +2150,7 @@ local function GetSingleUnitInfoPanel(parentControl, isTooltipVersion) local healthPos if shieldBarUpdate then if ud and ((ud.shieldPower or 0) > 0 or ud.level) then - local shieldPower = (spGetUnitRulesParam(unitID, "comm_shield_max") or ud.shieldPower) * (Spring.GetUnitRulesParam(unitID, "totalShieldMaxMult") or 1) + local shieldPower = (spGetUnitRulesParam(unitID, "comm_shield_max") or ud.shieldPower or 0) * (Spring.GetUnitRulesParam(unitID, "totalShieldMaxMult") or 1) local _, shieldCurrentPower = spGetUnitShieldState(unitID, -1) if shieldCurrentPower and shieldPower then shieldBarUpdate(true, nil, shieldCurrentPower, shieldPower, (shieldCurrentPower < shieldPower) and GetUnitShieldRegenString(unitID, ud)) diff --git a/LuaUI/Widgets/missile_command_center.lua b/LuaUI/Widgets/missile_command_center.lua new file mode 100644 index 0000000000..786d174334 --- /dev/null +++ b/LuaUI/Widgets/missile_command_center.lua @@ -0,0 +1,717 @@ +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:GetInfo() + return { + name = "Missile Command Center", + desc = "Adds missile launch commands and previews where each shot will land, marking terrain that blocks it.", + author = "Amnykon", + date = "2021-07-30", + license = "GNU GPL, v2 or later", + layer = 0, + handler = true, + enabled = true, + } +end + +function widget:Initialize() + WG.missileActiveIcons = {} +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +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 + +-- Faint ring at the intended target, drawn when the shot is blocked so it is +-- clear the impact ring has been relocated short of where the player aimed. +local function drawGhostTarget(tx, ty, tz, weaponDef) + glLineWidth(1) + glColor(1, 1, 1, 0.25) + DrawCircle(tx, ty, tz, weaponDef.damageAreaOfEffect) + glColor(1, 1, 1, 1) +end + +local function drawLine(x1, y1, z1, x2, y2, z2) + glPushAttrib(GL.LINE_BITS) + glLineStipple("springdefault") + glDepthTest(false) + glLineWidth(1) + glColor(1, 0, 0, 1) + glBeginEnd(GL.LINES, function() + glVertex(x1, y1, z1) + glVertex(x2, y2, z2) + end) + + glColor(1, 1, 1, 1) + glLineStipple(false) + glPopAttrib() +end + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +local function getMouseTargetPosition() + local mx, my = Spring.GetMouseState() + local mouseTargetType, mouseTarget = Spring.TraceScreenRay(mx, my, false, true, false, true) + + if (mouseTargetType == "ground") then + return mouseTarget[1], mouseTarget[2], mouseTarget[3], true + elseif (mouseTargetType == "unit") then + return Spring.GetUnitPosition(mouseTarget) + elseif (mouseTargetType == "feature") then + local _, coords = Spring.TraceScreenRay(mx, my, true, true, false, true) + if coords and coords[3] then + return coords[1], coords[2], coords[3], true + else + return Spring.GetFeaturePosition(mouseTarget) + end + else + return nil + end +end + +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 missile_class() + local self = {} + + self.cmdType = CMDTYPE.ICON_MAP + + function self:getOrderableUnits() + local teamUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + local units = {} + + for _, unitID in ipairs(teamUnits) do + if self:canGiveOrder(unitID) then + units[#units + 1] = unitID + end + end + + return units + end + + function self:getNumberOfQueueLaunches(unit) + local unitDefID = Spring.GetUnitDefID(unit) + if not unitDefID then return 0 end + + local unitType = self.launchableTypes[unitDefID] + if not unitType then return 0 end + + local numStockpiled = unitType.getStockpile(unit) + if not numStockpiled or numStockpiled == 0 then return 0 end + + local cmdQueue = Spring.GetUnitCommands(unit, 100) + if not cmdQueue then return 0 end + + local numQueued = 0 + for _, cmd in ipairs(cmdQueue) do + if cmd and cmd.id == unitType.launchCmd then numQueued = numQueued + 1 end + end + + return numQueued + end + + function self:getCount() + local count = 0 + for _, unit in ipairs(self:getOrderableUnits()) do + if not Spring.GetUnitIsDead(unit) then + local unitDefID = Spring.GetUnitDefID(unit) + if unitDefID then + local type = self.launchableTypes[unitDefID] + if type then + local stockpile = type.getStockpile(unit) + if stockpile then + count = count + stockpile - self:getNumberOfQueueLaunches(unit) + end + end + end + end + end + return count + end + + function self:getMaxBuildProgress() + local maxProgress = 0 + local allUnits = Spring.GetTeamUnits(Spring.GetMyTeamID()) or {} + + for _, unitID in ipairs(allUnits) do + if not Spring.GetUnitIsDead(unitID) then + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID and self.launchableTypes[unitDefID] then + -- Silo-built missiles exist as nanoframes while under construction. + local _, _, _, _, buildProgress = Spring.GetUnitHealth(unitID) + if buildProgress and buildProgress < 1 then + maxProgress = math.max(maxProgress, buildProgress) + else + -- Stockpiling weapons (Trinity, Reef, subtac) report progress toward + -- the next missile via the "gadgetStockpile" rules param. Zero-K + -- reimplements stockpiling in a gadget, so the engine's + -- GetUnitStockpile build percent is pinned to 1 and unusable here. + local stockpileProgress = Spring.GetUnitRulesParam(unitID, "gadgetStockpile") + if stockpileProgress and stockpileProgress > 0 and stockpileProgress < 1 then + maxProgress = math.max(maxProgress, stockpileProgress) + end + end + end + end + end + return maxProgress + end + + function self:canGiveOrder(unit) + local _, _, _, _, build = Spring.GetUnitHealth(unit) + local type = self.launchableTypes[Spring.GetUnitDefID(unit)] + if not type then return false end + + local count = type.getStockpile(unit) + - self:getNumberOfQueueLaunches(unit) + + return build == 1 and count ~= 0 + end + + function self:preferredUnit(unit1, unit2, params) + local unit2x, _, unit2z = Spring.GetUnitPosition(unit2) + if not unit2x then return unit1 end + + local type2 = self.launchableTypes[Spring.GetUnitDefID(unit2)] + if not type2 then return unit1 end + + local unit2Dist = 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 + + if unit2Dist > range * range then return unit1 end + + if not unit1 then return unit2 end + + local type1 = self.launchableTypes[Spring.GetUnitDefID(unit1)] + if not type1 then return unit2 end + + local weaponDef1 = WeaponDefs[UnitDefs[Spring.GetUnitDefID(unit1)].weapons[type1.weaponId].weaponDef] + if not weaponDef1 then return unit2 end + + 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:getPreferredUnit(params) + local units = self:getOrderableUnits() + + params.selectedUnits = {} + for _, unit in ipairs(Spring.GetSelectedUnits() or {}) do + params.selectedUnits[unit] = true + end + + local preferredUnit + + for _, unitID in ipairs(units) do + if self:canGiveOrder(unitID) then + preferredUnit = self:preferredUnit(preferredUnit, unitID, params) + end + end + + return preferredUnit + end + + function self:commandsChanged() + local customCommands = widgetHandler.customCommands + + -- All fields must be present and valid, or the engine logs + -- "GetLuaCmdDescList() bad entry" for the descriptor. name is also used by + -- the integral menu to draw the stockpile count. + customCommands[#customCommands+1] = { + id = self.cmd, + type = self.cmdType, + name = self.displayName or "", + cursor = 'Attack', + action = "missile_" .. self.name, + texture = "LuaUI/Images/commands/Bold/missile.png", + tooltip = "Launch missile.", + disabled = self.disabled or false, + params = {}, + } + end + + function self:commandNotify(cmdID, cmdParams, cmdOptions) + if cmdID == self.cmd then + local x,y,z + if #cmdParams == 1 then + x,y,z = Spring.GetUnitPosition(cmdParams[1]) + else + x,y,z = cmdParams[1], cmdParams[2], cmdParams[3] + end + local unit = self:getPreferredUnit{x = x, z = z} + if not unit then return true end + local unitType = self.launchableTypes[Spring.GetUnitDefID(unit)] + if not unitType then return true end + + -- Insert after any launches already queued but before other orders (e.g. + -- moves), so multiple shift-clicks fire in click order and still launch + -- before the unit moves away. + local insertPos = 0 + local cmdQueue = Spring.GetUnitCommands(unit, 100) + if cmdQueue then + for i = 1, #cmdQueue do + if cmdQueue[i].id == unitType.launchCmd then + insertPos = i + else + break + end + end + end + Spring.GiveOrderToUnit(unit, CMD.INSERT, {insertPos, unitType.launchCmd, CMD.OPT_SHIFT, unpack(cmdParams)}, CMD.OPT_ALT) + return true + end + end + + function self:action(x, y, mouse) + if self:getCount() == 0 then return end + + local cmdIndex = Spring.GetCmdDescIndex(self.cmd) + if not cmdIndex then return end + + local left, right = true, false + local alt, ctrl, meta, shift = Spring.GetModKeyState() + Spring.SetActiveCommand(cmdIndex, 1, left, right, alt, ctrl, meta, shift) + end + + 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:getPreferredUnit{x = mx, z = mz} + if not unit then return end + + local unitDefID = Spring.GetUnitDefID(unit) + if not unitDefID then return end + + local unitType = self.launchableTypes[unitDefID] + if not unitType then return end + + local unitDef = UnitDefs[unitDefID] + if not unitDef or not unitDef.weapons then return end + + -- Fire origin computed the same way as the Attack AoE widget (aim midpoint, + -- plus unit radius for immobile launchers) so both previews agree. + local _, _, _, ux, uy, uz = Spring.GetUnitPosition(unit, true) + if not ux then return end + if unitDef.isImmobile then + uy = uy + Spring.GetUnitRadius(unit) + end + + local weapon = unitDef.weapons[unitType.weaponId] + if not weapon then return end + + local weaponDef = WeaponDefs[weapon.weaponDef] + if not weaponDef then return end + + local dist = distance(mx, mz, ux, uz) + local range = weaponDef.range + if dist > range * range then return end + + -- Relocate the impact to any terrain that blocks the shot, using the same + -- trajectory model the Attack AoE widget uses when the missile is selected + -- directly, so the two previews always agree. + local ix, iy, iz = mx, my, mz + local blocked = false + if WG.AttackAoE and WG.AttackAoE.GetVlaunchImpact then + local hx, hy, hz = WG.AttackAoE.GetVlaunchImpact(weapon.weaponDef, ux, uy, uz, mx, my, mz) + if hx then + ix, iy, iz, blocked = hx, hy, hz, true + end + end + + if blocked then + drawGhostTarget(mx, my, mz, weaponDef) + end + drawBlastRadius(ix, iy, iz, weaponDef) + drawLine(ux, uy, uz, ix, iy, iz) + end + + + return self +end + +-- Silo-launched missiles (tacnuke, seismic, empmissile, napalmmissile, +-- missileslow) sit as a unit parked on their silo pad; one counts as stockpiled +-- while it exists and is still next to its silo. +local function siloMissileStockpile(unit) + if Spring.GetUnitIsDead(unit) then return 0 end + + local silo = Spring.GetUnitRulesParam(unit, "missile_parentSilo") + if not silo or Spring.GetUnitIsDead(silo) then return 0 end + + local x1, 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 = missile_class() + self.x = 438 + self.y = 38 + self.name = "tacnuke" + self.cmd = 39610 + + self.launchableTypes = { + [UnitDefNames["tacnuke"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = siloMissileStockpile + }, + [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 = missile_class() + self.x = 482 + self.y = 38 + self.name = "seismic" + self.cmd = 39611 + + self.launchableTypes = { + [UnitDefNames["seismic"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = siloMissileStockpile + }, + } + + return self +end + +local function shockley_controller_class() + local self = missile_class() + self.x = 526 + self.y = 38 + self.name = "empmissile" + self.cmd = 39612 + + self.launchableTypes = { + [UnitDefNames["empmissile"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = siloMissileStockpile + }, + } + + return self +end + +local function inferno_controller_class() + local self = missile_class() + self.x = 570 + self.y = 38 + self.name = "napalmmissile" + self.cmd = 39613 + + self.launchableTypes = { + [UnitDefNames["napalmmissile"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = siloMissileStockpile + }, + } + + return self +end + +local function slow_missile_controller_class() + local self = missile_class() + self.x = 614 + self.y = 38 + self.name = "missileslow" + self.cmd = 39616 + + self.launchableTypes = { + [UnitDefNames["missileslow"].id] = { + launchCmd = CMD.ATTACK, + weaponId = 1, + getStockpile = siloMissileStockpile + }, + } + + return self +end + +local function reef_missile_controller_class() + local self = missile_class() + self.x = 394 + self.y = 38 + self.name = "shipcarrier" + self.cmd = 39614 + self.cmdType = CMDTYPE.ICON_UNIT_OR_MAP + + 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 = missile_class() + self.x = 350 + self.y = 38 + self.name = "staticnuke" + self.cmd = 39615 + + 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(), + slowMissile = slow_missile_controller_class(), + reefMissile = reef_missile_controller_class(), + 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 + +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:CommandsChanged() + for _, command in pairs(commands) do + command:commandsChanged() + end +end + +function widget:Update(dt) + timer = timer + dt + if timer < UPDATE_FREQUENCY then + return + end + timer = 0 + + local changed = false + local activeIcons = {} + + for _, command in ipairs(orderedCommands) do + local count = command:getCount() + local buildProgress = command:getMaxBuildProgress() + + -- 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 + -- DRAW_NAME_COMMANDS / commandDisplayConfig.drawName). + local displayName = "" + if count > 0 then + displayName = "x" .. count + end + + -- Factory-style build progress bar on the button. + if WG.IntegralMenu and WG.IntegralMenu.SetCommandProgress then + WG.IntegralMenu.SetCommandProgress(command.cmd, buildProgress) + end + + local disabled = (count == 0) + if command.displayName ~= displayName or command.disabled ~= disabled then + command.displayName = displayName + command.disabled = disabled + changed = true + end + end + + -- Export active-missile icons for the tab badge. + WG.missileActiveIcons = activeIcons + + -- The integral menu only re-reads custom commands on CommandsChanged, which + -- the command menu pipeline does not run on its own while nothing is selected. + -- Force a rebuild when the shown count/progress changed, or once when the + -- selection first becomes empty, so the missiles tab stays available. + local emptySelection = (Spring.GetSelectedUnitsCount() == 0) + if changed or (emptySelection and not wasEmptySelection) then + Spring.ForceLayoutUpdate() + end + wasEmptySelection = emptySelection +end + +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:DrawWorld() + for _, command in pairs(commands) do + command:drawWorld() + end +end +