From af51d1e5e736930767b1e63dcdb8c520da8969a8 Mon Sep 17 00:00:00 2001 From: Zaffer <51871197+Zaffer@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:21:48 -0400 Subject: [PATCH 01/17] feat(camera): cycle camera on selected units (#7339) Add a new widget (`cmd_cycle_selected.lua`) that lets the player cycle camera focus through each unit in the current selection using `.` (next) and `,` (previous), without changing the selection itself. --- luaui/Widgets/cmd_cycle_selected.lua | 89 +++++++++++++++++++++ luaui/configs/hotkeys/grid_keys.txt | 4 + luaui/configs/hotkeys/grid_keys_60pct.txt | 4 + luaui/configs/hotkeys/legacy_keys.txt | 4 + luaui/configs/hotkeys/legacy_keys_60pct.txt | 4 + 5 files changed, 105 insertions(+) create mode 100644 luaui/Widgets/cmd_cycle_selected.lua diff --git a/luaui/Widgets/cmd_cycle_selected.lua b/luaui/Widgets/cmd_cycle_selected.lua new file mode 100644 index 00000000000..dc9e94d215b --- /dev/null +++ b/luaui/Widgets/cmd_cycle_selected.lua @@ -0,0 +1,89 @@ +local widget = widget ---@type Widget + +function widget:GetInfo() + return { + name = "Cycle Selected Units", + desc = "Cycle camera focus through each unit in the current selection", + author = "Zaffer", + date = "April 2026", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = true, + } +end + + +-- We use unitIndex as a cursor for cycling on subsequent triggers +local unitIndex = 0 + +-- Snapshot of the selection we're cycling through +local cycleUnits = {} + +local function selectionChanged(currentSel) + if #currentSel ~= #cycleUnits then + return true + end + local currentSet = {} + for _, uid in ipairs(currentSel) do + currentSet[uid] = true + end + for _, uid in ipairs(cycleUnits) do + if not currentSet[uid] then + return true + end + end + return false +end + +local function resetCycle(currentSel) + cycleUnits = currentSel + table.sort(cycleUnits) + unitIndex = 0 +end + +local function focusUnit(unitID) + local x, y, z = Spring.GetUnitPosition(unitID) + if x then + Spring.SetCameraTarget(x, y, z) + return true + end + return false +end + +local function handleCycleSelected(_, _, words) + local currentSel = Spring.GetSelectedUnits() + if #currentSel < 1 then + return + end + + if selectionChanged(currentSel) then + resetCycle(currentSel) + end + + local unitCount = #cycleUnits + if unitCount < 1 then + return + end + + local direction = (words and words[1] == "prev") and -1 or 1 + + -- Try up to unitCount times in case some IDs became invalid + for _ = 1, unitCount do + -- Advance cursor, wrapping around in either direction + unitIndex = ((unitIndex - 1 + direction) % unitCount) + 1 + if focusUnit(cycleUnits[unitIndex]) then + break + end + end + + -- Halt the action chain + return true +end + +function widget:Shutdown() + widgetHandler:RemoveAction("cycleselected") +end + +function widget:Initialize() + widgetHandler:AddAction("cycleselected", handleCycleSelected, nil, "p") +end diff --git a/luaui/configs/hotkeys/grid_keys.txt b/luaui/configs/hotkeys/grid_keys.txt index f97ee09a8bf..023445214f2 100644 --- a/luaui/configs/hotkeys/grid_keys.txt +++ b/luaui/configs/hotkeys/grid_keys.txt @@ -141,6 +141,10 @@ bind Ctrl+sc_r select AllMap+_Transport_Idle+_ClearSelection_SelectAll bind Ctrl+sc_y select Visible+_Waiting+_ClearSelection_SelectAll+ bind Alt+sc_q select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+ +// cycle camera through selected units +bind sc_. cycleselected next +bind sc_, cycleselected prev + // numpad movement bind numpad2 moveback bind numpad6 moveright diff --git a/luaui/configs/hotkeys/grid_keys_60pct.txt b/luaui/configs/hotkeys/grid_keys_60pct.txt index ba1d3cb2712..a11810aa1b8 100644 --- a/luaui/configs/hotkeys/grid_keys_60pct.txt +++ b/luaui/configs/hotkeys/grid_keys_60pct.txt @@ -143,6 +143,10 @@ bind Ctrl+sc_r select AllMap+_Transport_Idle+_ClearSelection_SelectAll bind Ctrl+sc_y select Visible+_Waiting+_ClearSelection_SelectAll+ bind Ctrl+Alt+sc_q select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+ +// cycle camera through selected units +bind sc_. cycleselected next +bind sc_, cycleselected prev + // numpad movement bind numpad2 moveback bind numpad6 moveright diff --git a/luaui/configs/hotkeys/legacy_keys.txt b/luaui/configs/hotkeys/legacy_keys.txt index 8ca8a85116a..8ecd65ab1d8 100644 --- a/luaui/configs/hotkeys/legacy_keys.txt +++ b/luaui/configs/hotkeys/legacy_keys.txt @@ -114,6 +114,10 @@ bind Ctrl+sc_w select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+ bind Ctrl+sc_x select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+ bind Ctrl+sc_z select AllMap+_InPrevSel+_ClearSelection_SelectAll+ +// cycle camera through selected units +bind sc_. cycleselected next +bind sc_, cycleselected prev + // building hotkeys bind sc_z buildunit_armmex bind Shift+sc_z buildunit_armmex diff --git a/luaui/configs/hotkeys/legacy_keys_60pct.txt b/luaui/configs/hotkeys/legacy_keys_60pct.txt index 388d78ce94e..6d9df437fa8 100644 --- a/luaui/configs/hotkeys/legacy_keys_60pct.txt +++ b/luaui/configs/hotkeys/legacy_keys_60pct.txt @@ -118,6 +118,10 @@ bind Ctrl+sc_w select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+ bind Ctrl+sc_x select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+ bind Ctrl+sc_z select AllMap+_InPrevSel+_ClearSelection_SelectAll+ +// cycle camera through selected units +bind sc_. cycleselected next +bind sc_, cycleselected prev + // building hotkeys bind sc_z buildunit_armmex bind Shift+sc_z buildunit_armmex From 1a3bc21020a52c7b7775620d3b39562e6e040c7f Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Wed, 26 Aug 2026 12:25:49 -0600 Subject: [PATCH 02/17] types/luassert: assertions take a message, and assert is callable (#8891) Every assertion gains a `message?` parameter, verified against the actual source. https://github.com/LuaCATS/luassert/pull/8 for more info --- types/luassert/library/luassert.lua | 65 +++++++++++++++++++---------- types/luassert/provenance.md | 13 ++++++ 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/types/luassert/library/luassert.lua b/types/luassert/library/luassert.lua index 864298fa21a..1122e4cf54d 100644 --- a/types/luassert/library/luassert.lua +++ b/types/luassert/library/luassert.lua @@ -3,28 +3,34 @@ ---@class luassert.internal local internal = {} +---`assert(value, message?)` is luassert's own assertion too: it fails with +---the message and returns its arguments, as the standard assert does. ---@class luassert:luassert.internal +---@overload fun(value: any, message?: any, ...: any): any, ... local luassert = {} --#region Assertions ---Assert that `value == true`. ---@param value any The value to confirm is `true`. -function internal.True(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.True(value, message) end internal.is_true = internal.True internal.is_not_true = internal.True ---Assert that `value == false`. ---@param value any The value to confirm is `false`. -function internal.False(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.False(value, message) end internal.is_false = internal.False internal.is_not_false = internal.False ---Assert that `type(value) == "boolean"`. ---@param value any The value to confirm is of type `boolean`. -function internal.Boolean(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Boolean(value, message) end internal.boolean = internal.Boolean internal.is_boolean = internal.Boolean @@ -32,7 +38,8 @@ internal.is_not_boolean = internal.Boolean ---Assert that `type(value) == "number"`. ---@param value any The value to confirm is of type `number`. -function internal.Number(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Number(value, message) end internal.number = internal.Number internal.is_number = internal.Number @@ -40,7 +47,8 @@ internal.is_not_number = internal.Number ---Assert that `type(value) == "string"`. ---@param value any The value to confirm is of type `string`. -function internal.String(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.String(value, message) end internal.string = internal.String internal.is_string = internal.String @@ -48,7 +56,8 @@ internal.is_not_string = internal.String ---Assert that `type(value) == "table"`. ---@param value any The value to confirm is of type `table`. -function internal.Table(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Table(value, message) end internal.table = internal.Table internal.is_table = internal.Table @@ -56,14 +65,16 @@ internal.is_not_table = internal.Table ---Assert that `type(value) == "nil"`. ---@param value any The value to confirm is of type `nil`. -function internal.Nil(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Nil(value, message) end internal.is_nil = internal.Nil internal.is_not_nil = internal.Nil ---Assert that `type(value) == "userdata"`. ---@param value any The value to confirm is of type `userdata`. -function internal.Userdata(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Userdata(value, message) end internal.userdata = internal.Userdata internal.is_userdata = internal.Userdata @@ -71,14 +82,16 @@ internal.is_not_userdata = internal.Userdata ---Assert that `type(value) == "function"`. ---@param value any The value to confirm is of type `function`. -function internal.Function(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Function(value, message) end internal.is_function = internal.Function internal.is_not_function = internal.Function ---Assert that `type(value) == "thread"`. ---@param value any The value to confirm is of type `thread`. -function internal.Thread(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.Thread(value, message) end internal.thread = internal.Thread internal.is_thread = internal.Thread @@ -86,7 +99,8 @@ internal.is_not_thread = internal.Thread ---Assert that a value is truthy. ---@param value any The value to confirm is truthy. -function internal.truthy(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.truthy(value, message) end internal.Truthy = internal.truthy internal.is_truthy = internal.truthy @@ -94,7 +108,8 @@ internal.is_not_truthy = internal.truthy ---Assert that a value is falsy. ---@param value any The value to confirm is falsy. -function internal.falsy(value) end +---@param message? any Failure message, shown when the assertion fails. +function internal.falsy(value, message) end internal.Falsy = internal.falsy internal.is_falsy = internal.falsy @@ -103,7 +118,8 @@ internal.is_not_falsy = internal.falsy ---Assert that a callback throws an error. ---@param callback function A callback function that should error ---@param error? string The specific error message that will be asserted -function internal.error(callback, error) end +---@param message? any Failure message, shown when the assertion fails. +function internal.error(callback, error, message) end internal.Error = internal.error internal.has_error = internal.error @@ -116,6 +132,7 @@ internal.has_no_error = internal.error ---@param actual string ---@param init? integer ---@param plain? boolean +---@param message? any Failure message, shown when the assertion fails. ---## Example --[[ ```lua @@ -134,7 +151,7 @@ internal.has_no_error = internal.error end) ``` ]] -function internal.matches(pattern, actual, init, plain) end +function internal.matches(pattern, actual, init, plain, message) end internal.is_matches = internal.matches internal.is_not_matches = internal.matches @@ -163,7 +180,8 @@ internal.is_not_match = internal.matches end) ``` ]] -function internal.near(expected, actual, tolerance) end +---@param message? any Failure message, shown when the assertion fails. +function internal.near(expected, actual, tolerance, message) end internal.Near = internal.near internal.is_near = internal.near @@ -173,8 +191,9 @@ internal.is_not_near = internal.near --- ---When comparing tables, a reference check will be used. ---@param expected any The expected value ----@param ... any Values to check the equality of -function internal.equal(expected, ...) end +---@param actual any The actual value +---@param message? any Failure message, shown when the assertion fails. +function internal.equal(expected, actual, message) end internal.Equal = internal.equal internal.are_equal = internal.equal @@ -184,8 +203,9 @@ internal.are_not_equal = internal.equal --- ---When comparing tables, a deep compare will be performed. ---@param expected any The expected value ----@param ... any Values to check -function internal.same(expected, ...) end +---@param actual any The actual value +---@param message? any Failure message, shown when the assertion fails. +function internal.same(expected, actual, message) end internal.Same = internal.same internal.are_same = internal.same @@ -203,6 +223,7 @@ internal.not_returned_arguments = internal.returned_arguments ---@param pattern string ---@param init? integer ---@param plain? boolean +---@param message? any Failure message, shown when the assertion fails. ---##Example --[[ ```lua @@ -219,7 +240,7 @@ internal.not_returned_arguments = internal.returned_arguments end) ``` ]] -function internal.error_matches(func, pattern, init, plain) end +function internal.error_matches(func, pattern, init, plain, message) end internal.no_error_matches = internal.error_matches @@ -250,6 +271,8 @@ internal.are_not_all_near = internal.all_near --- array is uniqued ---@param arr any[] +---@param deep? boolean Compare elements with a deep comparison. +---@param message? any Failure message, shown when the assertion fails. ---## Example ---```lua ---it("Checks to see if table1 only contains unique elements", function() @@ -261,7 +284,7 @@ internal.are_not_all_near = internal.all_near --- assert.is_not.unique(tablenotunique) --- end) ---``` -function internal.unique(arr) end +function internal.unique(arr, deep, message) end internal.is_unique = internal.unique internal.is_not_unique = internal.unique diff --git a/types/luassert/provenance.md b/types/luassert/provenance.md index 8cbb8c29c58..a05daddbdbd 100644 --- a/types/luassert/provenance.md +++ b/types/luassert/provenance.md @@ -31,3 +31,16 @@ convention. If license clarity matters for a downstream distribution, open an issue at the upstream repo requesting an explicit `LICENSE` file be added. + +## Local changes + +- Every assertion takes the optional failure `message` luassert itself + accepts as its last argument (`is_true(v, msg)`, `equals(a, b, msg)`, + `near(a, b, tol, msg)`, `matches(p, s, init, plain, msg)`, + `has_error(fn, err, msg)`, `unique(arr, deep, msg)`, …); upstream + declares none of them, so `assert.is_true(x, "why")` reported + `redundant-parameter` on every use. `equals`/`same` are `(expected, + actual, message?)`, not `(expected, ...)`: luassert compares exactly two. +- `luassert` is callable: `assert(value, message?)` is luassert's own + assertion (it wraps the standard one), and the class had no call + signature, so every plain `assert(...)` in a spec was `call-non-callable`. From 865d0a4cb8904964c801e1189e51083eacf1bae1 Mon Sep 17 00:00:00 2001 From: "Clarence \"Sparr\" Risher" Date: Wed, 26 Aug 2026 14:26:14 -0400 Subject: [PATCH 03/17] Warn when alt-click will cancel a factory's current build (#8889) When holding alt to click a unit in a factory build menu, add a red highlight to the progress indicator for the current building unit if clicking would cause that unit to be canceled. The red section gets larger as the unit is closer to finished, giving visual feedback corresponding to how much progress would be lost. --- luaui/Widgets/gui_gridmenu.lua | 77 +++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/luaui/Widgets/gui_gridmenu.lua b/luaui/Widgets/gui_gridmenu.lua index 4e97568c583..db713412bec 100644 --- a/luaui/Widgets/gui_gridmenu.lua +++ b/luaui/Widgets/gui_gridmenu.lua @@ -704,6 +704,53 @@ local function updateBuildProgress() redrawProgress = true end +-- State and parameters for warning the player when alt-click to add a unit to +-- the front of the queue will cause a current build to be canceled +local cancelWarning = { + -- Is the build shown by the progress indicator about to be thrown away? + ---@type boolean + active = false, + ---@type rgba + color = { 0.8, 0.05, 0.05, 0.45 }, +} + +-- Applies the same conditions as engine FactoryCAI::GiveCommandReal to +-- determine whether alt-click will cancel the currently building unit +---@return boolean +function cancelWarning.isPending() + if disableInput or not builderIsFactory or not activeBuilderID or not currentlyBuildingRectID then + return false + end + + local alt = Spring.GetModKeyState() + if not alt then + return false + end + + -- Re-picking whatever is already in production leaves the buildee untouched + local hoveredDefID = WG.buildmenu.hoverID + local hoveredCellID = hoveredDefID and uDefCellIds[hoveredDefID] + if not hoveredCellID or hoveredCellID == currentlyBuildingRectID then + return false + end + + if cellRects[hoveredCellID].opts.disabled then + return false + end + + -- Repeat mode inserts behind the queue front rather than replacing it + local _, _, _, repeatOrders = Spring.GetUnitStates(activeBuilderID, false, true) + return not repeatOrders +end + +function cancelWarning.update() + local pending = cancelWarning.isPending() + if pending ~= cancelWarning.active then + cancelWarning.active = pending + redrawProgress = true + end +end + local function updateSelectedCell() for i = 1, cellCount do local cellRect = cellRects[i] @@ -1933,6 +1980,8 @@ function widget:Update(dt) doUpdateClock = nil doUpdate = nil end + + cancelWarning.update() end ------------------------------------------------------------------------------- @@ -2714,15 +2763,33 @@ local function drawBuildProgress(cellRect) return end + local x1 = cellRect.x + cellPadding + iconPadding + local y1 = cellRect.y + cellPadding + iconPadding + local x2 = cellRect.xEnd - cellPadding - iconPadding + local y2 = cellRect.yEnd - cellPadding - iconPadding + local cornerRadius = cellSize * 0.03 + RectRoundProgress( - cellRect.x + cellPadding + iconPadding, - cellRect.y + cellPadding + iconPadding, - cellRect.xEnd - cellPadding - iconPadding, - cellRect.yEnd - cellPadding - iconPadding, - cellSize * 0.03, + x1, + y1, + x2, + y2, + cornerRadius, 1 - cellRect.opts.progress, -- make the effect wind counter-clockwise { 0.08, 0.08, 0.08, 0.6 } ) + + -- Fill the sector already built, so the red covers exactly the progress an alt-click + -- would throw away and grows with it. RectRoundProgress only ever winds one way from + -- the top, so mirror it across the cell to land in the gap the shading leaves rather + -- than on top of the shading itself. + if cancelWarning.active then + gl.PushMatrix() + gl.Translate(x1 + x2, 0, 0) + gl.Scale(-1, 1, 1) + RectRoundProgress(x1, y1, x2, y2, cornerRadius, cellRect.opts.progress, cancelWarning.color) + gl.PopMatrix() + end end ------------------------------------------------------------------------------- From c6c02fd1eb4ff8d0149cb2109ec485ad53f6f4c8 Mon Sep 17 00:00:00 2001 From: efrec Date: Wed, 26 Aug 2026 14:29:33 -0400 Subject: [PATCH 04/17] Set Target: match target tracking of builtin commands (#8777) Set Target tracks a lost unit for only one SlowUpdate interval (0.5 seconds) before untracking it. On average, this interval is halved; we have just 0.25s of safety. Other commands have a 2.0s target-lost timer, this PR adds the same grace period to Set Target to prevent early loss of tracking on enemies. --- luarules/gadgets/unit_target_on_the_move.lua | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/luarules/gadgets/unit_target_on_the_move.lua b/luarules/gadgets/unit_target_on_the_move.lua index 64f0599066a..b0f85147f35 100644 --- a/luarules/gadgets/unit_target_on_the_move.lua +++ b/luarules/gadgets/unit_target_on_the_move.lua @@ -20,12 +20,14 @@ local CMD_UNIT_SET_TARGET_RECTANGLE = GameCMD.UNIT_SET_TARGET_RECTANGLE if gadgetHandler:IsSyncedCode() then local deleteMaxDistance = 30 local targetListLengthMax = 128 + local unseenGraceTime = 1.5 local spInsertUnitCmdDesc = Spring.InsertUnitCmdDesc local spGetUnitAllyTeam = Spring.GetUnitAllyTeam local spSetUnitTarget = Spring.SetUnitTarget local spValidUnitID = Spring.ValidUnitID local spGetUnitDefID = Spring.GetUnitDefID + local spGetUnitIsDead = Spring.GetUnitIsDead local spGetUnitLosState = Spring.GetUnitLosState local spGetUnitTeam = Spring.GetUnitTeam local spAreTeamsAllied = Spring.AreTeamsAllied @@ -160,6 +162,8 @@ if gadgetHandler:IsSyncedCode() then end end + local unseenGracePasses = math.floor(unseenGraceTime / 0.5) + -------------------------------------------------------------------------------- -- Commands @@ -321,12 +325,18 @@ if gadgetHandler:IsSyncedCode() then SendToUnsynced("targetIndex", unitID, 1, false) end - local function isUnseenEnemyUnit(targetData, allyTeam) - if targetData.alwaysSeen or not spValidUnitID(targetData.target) then - return false + local function wasTargetLost(target, alwaysSeen, allyTeam) + if type(target) ~= "number" then + return false, false + elseif alwaysSeen then + local isDead = spGetUnitIsDead(target) ~= false + return isDead, isDead end - local los = spGetUnitLosState(targetData.target, allyTeam, true) - return not los or los % 4 == 0 + local los = spGetUnitLosState(target, allyTeam, true) + if not los then + return true, true + end + return los % 4 == 0, false end -------------------------------------------------------------------------------- @@ -690,6 +700,7 @@ if gadgetHandler:IsSyncedCode() then ignoreStop = ignoreStop, userTarget = userTarget, target = target, + unseen = unseenGracePasses, sent = false, } end @@ -716,6 +727,7 @@ if gadgetHandler:IsSyncedCode() then ignoreStop = ignoreStop, userTarget = userTarget, target = target, + unseen = unseenGracePasses, sent = false, }, } @@ -730,6 +742,7 @@ if gadgetHandler:IsSyncedCode() then ignoreStop = ignoreStop, userTarget = userTarget, target = target, + unseen = unseenGracePasses, sent = false, }, } @@ -833,7 +846,13 @@ if gadgetHandler:IsSyncedCode() then for unitID, unitData in pairsNext, setTargetData do local targets = unitData.targets for index = #targets, 1, -1 do - if isUnseenEnemyUnit(targets[index], unitData.allyTeam) then + local targetData = targets[index] + local isLost, isDead = wasTargetLost(targetData.target, targetData.alwaysSeen, unitData.allyTeam) + if not isLost then + targetData.unseen = unseenGracePasses + elseif not isDead and targetData.unseen > 0 then + targetData.unseen = targetData.unseen - 1 + else removeTarget(unitID, unitData, index) end end From 18eb9013f0cf115240f1548eee84313c8fa6714e Mon Sep 17 00:00:00 2001 From: Egzothicki <97255880+Egzothicki@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:30:10 +0700 Subject: [PATCH 05/17] Fix Goblin backwards shots, twitchy aim and fire interruptions (#8901) Fix Goblin backwards shots, twitchy aim and fire interruptions --- luarules/gadgets/unit_continuous_aim.lua | 2 +- scripts/Units/leggob.bos | 50 ++++++++++++++++------- scripts/Units/leggob.cob | Bin 9087 -> 9439 bytes units/Legion/Bots/leggob.lua | 1 + 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/luarules/gadgets/unit_continuous_aim.lua b/luarules/gadgets/unit_continuous_aim.lua index e1174e33fd7..ad9f435e066 100644 --- a/luarules/gadgets/unit_continuous_aim.lua +++ b/luarules/gadgets/unit_continuous_aim.lua @@ -106,7 +106,7 @@ local convertedUnitsNames = { legcen = 3, legfloat = 5, leggat = 5, - leggob = 5, + leggob = 2, leggobt3 = 5, leginc = 1, cordemon = 6, diff --git a/scripts/Units/leggob.bos b/scripts/Units/leggob.bos index 40015f4f19c..3b5371cc727 100644 --- a/scripts/Units/leggob.bos +++ b/scripts/Units/leggob.bos @@ -3,7 +3,7 @@ piece torso, flare, sleeve, pelvis, rleg, rfoot, lleg, lfoot, lthigh, rthigh, aimx1, aimy1, lbarrel; -static-var isMoving, isAiming, wpnheading, animSpeed, maxSpeed, animFramesPerKeyframe, StompXZ, StompHeading; +static-var isMoving, wpnheading, animSpeed, maxSpeed, animFramesPerKeyframe, StompXZ, StompHeading, aimBelief, lastAimFrame; lua_UnitScriptDecal(lightIndex, xpos,zpos, heading) { @@ -14,6 +14,8 @@ lua_UnitScriptDecal(lightIndex, xpos,zpos, heading) #define SIGNAL_AIM1 256 #define SIGNAL_SHOOT1 16 #define SIGNAL_MOVE 1 + +#define AIM_SPEED_YAW <300> Walk() {// For C:\Users\logst\Downloads\BAR\leggob anim.blend Created by https://github.com/Beherith/Skeletor_S3O V((0, 4, 2)) set-signal-mask SIGNAL_MOVE; if (isMoving) { //Frame:0 @@ -197,8 +199,9 @@ Create() hide aimx1; hide aimy1; isMoving = FALSE; - isAiming = 0; wpnheading = 0; + aimBelief = 0; + lastAimFrame = 0; animSpeed = 4; } @@ -218,10 +221,11 @@ ExecuteRestoreAfterDelay() if (Stunned) { return (1); } - turn aimy1 to y-axis <0.0> speed <90>; + // home at the aim turn rate and restart the frame clock so the belief model stays true + lastAimFrame = get GAME_FRAME; + turn aimy1 to y-axis <0.0> speed AIM_SPEED_YAW; turn aimx1 to x-axis <15.0> speed <45>; wpnheading = 0; - isAiming = 0; } @@ -246,20 +250,36 @@ AimWeapon1(heading, pitch) { //get PRINT ( heading,isAiming,wpnheading,RAND(1,256) ) ; signal SIGNAL_AIM1; - - if (isAiming == 1) return; - turn aimy1 to y-axis heading speed <200>; - turn aimx1 to x-axis <0.0> - pitch speed <100>; - - //needed for luarules\gadgets\unit_continuous_aim.lua - if ((get ABS(wpnheading - heading)) > 500){ - isAiming = 1; - wait-for-turn aimy1 around y-axis; - wait-for-turn aimx1 around x-axis; - isAiming = 0; + set-signal-mask SIGNAL_AIM1; + + // advance the believed torso heading toward the previous goal at the physical turn rate + var frame; + var step; + var delta; + frame = get GAME_FRAME; + step = (frame - lastAimFrame) * (AIM_SPEED_YAW / 30); + lastAimFrame = frame; + delta = WRAPDELTA(wpnheading - aimBelief); + if ((get ABS(delta)) > step) + { + aimBelief = WRAPDELTA(aimBelief + SIGN(delta) * step); } + else + { + aimBelief = wpnheading; + } + + turn aimy1 to y-axis heading speed AIM_SPEED_YAW; + turn aimx1 to x-axis <0.0> - pitch speed <100>; wpnheading = heading; start-script RestoreAfterDelay(); + + // hold fire while the torso is still far from the current goal + delta = WRAPDELTA(heading - aimBelief); + if ((get ABS(delta)) > <25>) + { + return (0); + } return (1); } diff --git a/scripts/Units/leggob.cob b/scripts/Units/leggob.cob index 10b8ae4a06fc405b7b2585c29b5ad444fa24e26b..32ec477f258dad86cce120e0fa1fe57f96bb87a7 100644 GIT binary patch delta 1797 zcmbVNL1G3X#J6gMM85R?&0 z1Q&wS2Q7gX5)stWK&PX)l6Ii93svctJSus2LV6311Mdd0Vhv(xH9nv2Rb3 zx*FnQQk+UWDLgAYN3ND()EA>Z6UKxmglX?DpZ1y(zY;E_s2de73r`6*gcpQ^G&RG* zCE=PkU}w`AtlpM5@L)78Mwd~$Rd`%@Qg~K)PIytc&=uP4-OMR@IeVhp$84gT+2XVt zPCxnKmwH=uvUa*RA2@iOv~f)0#~+l75jPab^ez1EIqyw@`{=VK9E z{_#~ijF?|N^J{hG1-_>xAc->TfbGCe;1Tcy zSOU^`xR)ZOt^n78o4}vI9pEnT73WecjnHWadgi0mNTT4*K0;rG$B#5Nx`xV743&MiVn1i+Du^4qCdz* zLDF=f5E8USKZ~KJ*%TBwi->l)a3KUj5G2teYLR5v`+egT*A2YSIp6ty?t5mgE_APS zwT6Tc8=-3Gm@PyMD}7FSLOg|*pnh< z<{UAfgHfA}+SJg?9A%Qd8Jq0Qa=gU!qEy$-%rPgJW#%H&iBV04nPnEOW?hISaQc>G z$AZxq8?B<+eas=|7_-2fWBz4&VOJlBQ%}~~s?W!5>_+3%%}(QR`rki(wx%*Csi$fh z9TTsURE9F|-xDEP^(^OSYm!+hG^NtGYdlcVb zEq=&xk>h!$boL*nm!2Giv4+yCD`Gk)M?v0Ljj2wgO(i-YWh&|k=_`&OrMpz_@XYaB zf+*;xmHN1X_UI3#b)QqG?b_73<{h;@uGw4rP1-u+`qC~9x$liA{vO$<(9&~D9d-Q@ zRr%x*hm6q$*f7lC9Z#Ov5~W9sSSn^!vXbI{OHt)=yS|z_sDCHp`cP z0{cxu9D=%`Q_wJU4!Q{4gziF<&||0oy@5VJpP}#23Ka8&sDm1y7HGFG_J}rYGEfI} Z1nPl~L#Lr3=nQlY%0ZX>(zrh${{d}%_p|^2 diff --git a/units/Legion/Bots/leggob.lua b/units/Legion/Bots/leggob.lua index f0319ec8e56..74906e253b0 100644 --- a/units/Legion/Bots/leggob.lua +++ b/units/Legion/Bots/leggob.lua @@ -139,6 +139,7 @@ return { weapons = { [1] = { badtargetcategory = "VTOL", + burstcontrolwhenoutofarc = 2, def = "SEMIAUTO", onlytargetcategory = "NOTSUB", }, From 6448637603828b2180f09abbc185eeacf8b9c8d8 Mon Sep 17 00:00:00 2001 From: "Clarence \"Sparr\" Risher" Date: Wed, 26 Aug 2026 14:37:39 -0400 Subject: [PATCH 06/17] Fix loss of unit primitives after trying to draw >64 of them without geometry shaders (#8838) On machines without detected geometry shader support, every widget and gadget built on `DrawPrimitiveAtUnit` stops drawing entirely once it has more than 63 instances, and stays broken until the widget is reloaded. Widely used consumers are affected: rank icons, team platter, selected units, enemy spotter, unit trackers, etc. --- luaui/Include/DrawPrimitiveAtUnit.lua | 32 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/luaui/Include/DrawPrimitiveAtUnit.lua b/luaui/Include/DrawPrimitiveAtUnit.lua index f965da2f594..04c7fffca77 100644 --- a/luaui/Include/DrawPrimitiveAtUnit.lua +++ b/luaui/Include/DrawPrimitiveAtUnit.lua @@ -193,22 +193,46 @@ local function InitDrawPrimitiveAtUnit(shaderConfig, DPATname) DrawPrimitiveAtUnitVBO.nogsTemplateVBO = templateVBO DrawPrimitiveAtUnitVBO.nogsIndexVBO = indexVBO + -- The instance table doubles its buffer as soon as it fills up, and rebuilds its VAO from + -- the vertex and index buffers it finds on itself. Without these it would rebuild the VAO + -- of the geometry shader path instead, which draws this template mesh as bare points. + DrawPrimitiveAtUnitVBO.vertexVBO = templateVBO + DrawPrimitiveAtUnitVBO.indexVBO = indexVBO + -- Wrap the real VAO so existing consumers can keep calling -- VBO.VAO:DrawArrays(GL.POINTS, usedElements); under the hood we draw the - -- template mesh instanced once per element. - local indexCount = #indexData - DrawPrimitiveAtUnitVBO.VAO = { + -- template mesh instanced once per element. That rebuild on resize puts a plain VAO in the + -- field, so the wrapper is kept behind the field rather than in it, and takes over whatever + -- lands there. + local vaoWrapper = { realVAO = realVAO, - indexCount = indexCount, + indexCount = #indexData, DrawArrays = function(self, _primitiveType, instanceCount) if instanceCount and instanceCount > 0 then self.realVAO:DrawElements(GL.TRIANGLES, self.indexCount, 0, instanceCount) end end, + DrawElements = function(self, primitiveType, count, indexOffset, instanceCount, baseVertex) + self.realVAO:DrawElements(primitiveType, count, indexOffset, instanceCount, baseVertex) + end, Delete = function(self) self.realVAO:Delete() end, } + setmetatable(DrawPrimitiveAtUnitVBO, { + __index = function(_, key) + if key == "VAO" then + return vaoWrapper + end + end, + __newindex = function(instanceTable, key, value) + if key == "VAO" then + vaoWrapper.realVAO = value + else + rawset(instanceTable, key, value) + end + end, + }) end return DrawPrimitiveAtUnitVBO, DrawPrimitiveAtUnitShader end From 721399d0fe032279826593c0ad1e2455068bf3d8 Mon Sep 17 00:00:00 2001 From: Egzothicki <97255880+Egzothicki@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:41:37 +0700 Subject: [PATCH 07/17] Fix Epic Grunt aiming and firing instantly in any direction (#8784) The Epic Grunt aimed and fired instantly in any direction due to two bugs. They are now fixed. --- luarules/gadgets/unit_continuous_aim.lua | 1 + scripts/Units/scavboss/corakt4.bos | 11 ++++++----- scripts/Units/scavboss/corakt4.cob | Bin 49013 -> 49209 bytes scripts/Units/scavboss/weapon1control.h | 4 ++++ units/Scavengers/Bots/corakt4.lua | 4 ++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/luarules/gadgets/unit_continuous_aim.lua b/luarules/gadgets/unit_continuous_aim.lua index ad9f435e066..c38c4bcd99d 100644 --- a/luarules/gadgets/unit_continuous_aim.lua +++ b/luarules/gadgets/unit_continuous_aim.lua @@ -25,6 +25,7 @@ local convertedUnitsNames = { armbeamer = 3, armpw = 2, armpwt4 = 2, + corakt4 = 2, armflea = 2, armrock = 2, armham = 2, diff --git a/scripts/Units/scavboss/corakt4.bos b/scripts/Units/scavboss/corakt4.bos index 7b32a016cf2..2b7b6ebb5be 100644 --- a/scripts/Units/scavboss/corakt4.bos +++ b/scripts/Units/scavboss/corakt4.bos @@ -1093,12 +1093,13 @@ AimPrimary(heading, pitch) //get PRINT ( heading,bAiming,wpnheading,RAND(1,256) ) ; signal SIG_AIM; - start-script Weapon1Control(); - - call-script StopAnimation(); + if (!bMoving) + { + call-script StopAnimation(); + } call-script Weapon1Drawn(); - - start-script Weapon1SetWtdAim(heading, pitch); + + call-script Weapon1SetWtdAim(pitch, heading); start-script RestoreAfterDelay(); return (aim1); diff --git a/scripts/Units/scavboss/corakt4.cob b/scripts/Units/scavboss/corakt4.cob index 9c35dd21097dac7cb9402631a22cf6d260e0391d..63dc0c640de0466da71557fda9702adb0d604ee5 100644 GIT binary patch delta 888 zcmYk)ZAg<*6bJBo?z4t-QyEW?Vf1cU+9jRR%qS~NB8tkW50cOeDSfE4Afx{fq6dDQbDrlO?sH#${E>YDZG}My zu^1A;;1(hSG5V~%Bg6>ojtH>=^##PH;^gjv!dTv*<#4glz=K*0C2)n&gUVt}h_)4I zy2zfRivScG^+!ymB(CeR>H(u}(50Rn9fS`LRd?*T+1@fAv;Oz`evA-=`rrufv1O>y zxm{*@u1==sS?Gxx${IJ*19f!`!e!OC>6Dpz)VytZ7V}N}Y+GHD#(Qlpbz%?tn$_y^ zS~ERVk;85y?cm8OH)V6B*Ty;CwX_l0aw_DYcad}~@=;;aj!-bs;Kpa zMqMhiEkUDRUKdIsk17herF6JdLwkva#ZR6ulQQDs_IN@)R}9d zk37?J#55-ho30ym2X~un+|qkG@!y`e&z@6G7H5PP#1qCzY=88SL@MtLuc1XY%!B2?6 zFC~KmoUj3oL-d;U!!!5*zaeozh-Huu+o2q4;Ov0#ix84)&=14#7RF!#ra;~m!UA?k zhiu3N4{U*5Pzhc*13~D3+i(va!&7((@8CUrg0C=j*CoUZ5;=$!K^!E39n!!FnXqzD Na9YHwzTc^le*vb72IBw# delta 820 zcmXBSUr19?90&08J6DI>mE1~OsLf27{~JhPW>}C^hM7nSK3JNS6-D%4!|UBJFjh$N zvoZ`qio~GE&V!peeJRR_B8HhlPz;M67WtsnL(zA2;luf!dpP&U{hbr^YKwnlVVn>m z8bAz5lH`NI}qB{<)79!3r z?a8sdjTU>aYH~(&+g)7vey!)L)~tj?x~+Vkh^}9E{9j6m*Z7T|r~1ks>(r_$ zw_nkzOHJ37C6ZS;_D6JTR*T0vbyR9Q@3D}ZhuS?vTx2-;d;2C@iBv$DTyJE_sEa#{ zS}NzaMh;c-FXIP2<%JFp4RWSgLIYfH=Fl+v%&j!e&rA=QY#14w?6u2q2D`m(`6+{| zz1{fy2k&M%y+*lw`5KL@%1iwkomTT*Gm_dk!_bxnO-)gvQz}_H-V$I=Lor)fSuCV(k|EE#O)f3lgoSSg> - pitch; + if (((get ABS(curHead1 - wtdHead1)) > (Weapon1TurretY / 15)) OR ((get ABS(curPitch1 - wtdPitch1)) > (Weapon1TurretX / 15))) + { + aim1 = 0; + } } \ No newline at end of file diff --git a/units/Scavengers/Bots/corakt4.lua b/units/Scavengers/Bots/corakt4.lua index 601ab64ee2a..3068fe8ae5f 100644 --- a/units/Scavengers/Bots/corakt4.lua +++ b/units/Scavengers/Bots/corakt4.lua @@ -11,7 +11,7 @@ return { collisionvolumescales = "49 56 49", collisionvolumetype = "CylY", corpse = "DEAD", - explodeas = "explosiont3", + explodeas = "bantha", footprintx = 4, footprintz = 4, mass = 1000000, @@ -24,7 +24,7 @@ return { objectname = "Units/scavboss/CORAKT4.s3o", script = "Units/scavboss/CORAKT4.cob", seismicsignature = 0, - selfdestructas = "explosiont3xl", + selfdestructas = "banthaSelfd", sightdistance = 600, turninplace = true, turninplaceanglelimit = 90, From 6c847b25a86207820cbeb9afd16efc786a678789 Mon Sep 17 00:00:00 2001 From: Egzothicki <97255880+Egzothicki@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:42:09 +0700 Subject: [PATCH 08/17] Fix Grunt and Pawn sometimes firing sideways (#8764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Grunt and Pawn sometimes fire sideways. Beams/projectiles leave the gun while the torso points up to ~90° away from the target, most visibly when re-acquiring a target while turning. --- scripts/Units/armpw.bos | 68 +++++++++++++++++++++++++++++----------- scripts/Units/armpw.cob | Bin 42451 -> 43159 bytes scripts/Units/corak.bos | 56 ++++++++++++++++++++++++--------- scripts/Units/corak.cob | Bin 48614 -> 49266 bytes 4 files changed, 91 insertions(+), 33 deletions(-) diff --git a/scripts/Units/armpw.bos b/scripts/Units/armpw.bos index a475f1620d7..d39e52b073b 100644 --- a/scripts/Units/armpw.bos +++ b/scripts/Units/armpw.bos @@ -359,21 +359,45 @@ ExecuteRestoreAfterDelay() if (Stunned) { return (1); } - - //get PRINT(get GAME_FRAME, 90000, curHead1); - turn aimy1 to y-axis <0> speed <300.0>; - turn aimx1 to x-axis <0> speed <300.0>; - turn ruparm to x-axis <0> speed <300.0>; + + // clear before the interruptible restore so a resumed aim redraws the weapon + weaponReady = FALSE; + turn ruparm to x-axis <0> speed <300.0>; + turn luparm to x-axis <0> speed <300.0>; + turn ruparm to z-axis <12.799999> speed <300.0>; + turn luparm to z-axis <-12.099999> speed <300.0>; turn lloarm to x-axis <90> speed <300.0>; turn rloarm to x-axis <90> speed <300.0>; - curPitch1 = 0; - curHead1 = 0; - - weaponReady = FALSE; + // walk believed angles back in sync with the pieces (instant reset desyncs mid-restore aim) + set-signal-mask SIGNAL_AIM1; + var done; + var delta; + while (!done){ + done = TRUE; + delta = WRAPDELTA(0 - curHead1); + if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretY / 30))) { + curHead1 = curHead1 + SIGN(delta) * (Weapon1TurretY / 30); + done = FALSE; + }else{ + curHead1 = 0; + } + delta = WRAPDELTA(0 - curPitch1); + if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretX / 30))) { + curPitch1 = curPitch1 + SIGN(delta) * (Weapon1TurretX / 30); + done = FALSE; + }else{ + curPitch1 = 0; + } + turn aimy1 to y-axis curHead1 speed Weapon1TurretY; + turn aimx1 to x-axis <0> - curPitch1 speed Weapon1TurretX; + if (!done) { + sleep 32; + } + } isAiming = FALSE; - + } static-var bAnimate; @@ -1145,31 +1169,37 @@ AimWeapon1(heading, pitch) var canShoot; var delta; - + var nextHead; + var nextPitch; + while (!canShoot){ canShoot = TRUE; delta = WRAPDELTA(heading - curHead1); if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretY / 30))) { - curHead1 = curHead1 + SIGN(delta) * (Weapon1TurretY / 30); + nextHead = WRAPDELTA(curHead1 + SIGN(delta) * (Weapon1TurretY / 30)); canShoot = FALSE; }else{ - curHead1 = heading; + nextHead = heading; } delta = WRAPDELTA(pitch - curPitch1); if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretX / 30))) { - curPitch1 = curPitch1 + SIGN(delta) * (Weapon1TurretX / 30); + nextPitch = WRAPDELTA(curPitch1 + SIGN(delta) * (Weapon1TurretX / 30)); canShoot = FALSE; }else{ - curPitch1 = pitch; + nextPitch = pitch; } - + // Fun note, NOW does not override preexisting turn with other speed - turn aimy1 to y-axis curHead1 speed Weapon1TurretY; - turn aimx1 to x-axis <0> - curPitch1 speed Weapon1TurretX; - + turn aimy1 to y-axis nextHead speed Weapon1TurretY; + turn aimx1 to x-axis <0> - nextPitch speed Weapon1TurretX; + + // turn > sleep > commit: a preempting re-aim kills the thread at its sleep, + // cancelling the uncommitted step, so belief cannot outrun the pieces if (!canShoot) { sleep 32; } + curHead1 = nextHead; + curPitch1 = nextPitch; } start-script RestoreAfterDelay(); diff --git a/scripts/Units/armpw.cob b/scripts/Units/armpw.cob index 2391faddf0e0ad91c982b11cf2a8734774a1f147..4e4331e671058bb93c927e81828abb28daf6c796 100644 GIT binary patch delta 1098 zcmajeSx8i26bJC{J9nnssyi-OI?lMHIi<~zEf1|?NC<6K5)~yCiHV^$i-}H)QM3^0 zk!qkKX|o`xJ}{^l@}Y<}%Fu_Hq8@r^g~XzWsQ?svl zgb>+~0wpdXCSWyw-_8ord=4uV;dsG$Av&s4ug-rsg(D4d9C63-y=_HouTSDu#7A7I zv~Ylf6Epa@TjxV=7Y}$-(#&3`t-7clq3=!-Lh7VT?r2P5-8U^>B2DJbBA+wgg}env zaesEP8uexmj}?)5uCFY?+?hW@FHYjHJCncrO8#jz-S5ob7;6>(pH}I7#9w6l)2=3V zgMuIVm*<+hv0L96%VZmy9;pjl{9PiybT5`6F7_;;=20r>Dc8t5THP)Aq*3h{)jP9a zqXJzWm^DQ5vuc|=S(CTa#nQkl12|x)umFb`_rQN^0Cpp zEj^l#q(}3ianbzY@v5R4S82^qj!nH9t<6+}HRmNgb2VT zXu~rP6h=Ry)%cJaU@x42Yj6)m6}SnH zAOdgU3;ckQJwhad9rC~h9+(MppbRQt8LWc!uo>#00sPPkyI^k*Msf&2XOG~%Gij>p H%sl!Hteq8M delta 856 zcmYk)ZAep57zglkpS!73v#ZXRrslg&U2ZPPP^!T|gT%<9q!$z+C3QhrQ%B`e@I#QW zjwF$2>ZYUyt`jAJQWEwi!ZgEAy;FS%KZq55DEhDYp?mr9oO}Kc=iYlby*^rcNd^^T zECr&WL(fjOU^w;7oU$%&|b@yd@suQ^lBQh-~a7SaBShw}5rM=wMyhLM(yzbl#tt5)9 z^9z!c%D>x_r5qm9k{(40ZvW=oQY)+`NSRCGl`VF%CGi(6m3-2q~k!O+^^LP3i zRrvc5ZfmXAjAB-WSq=BKZ0F7bgTGfZzR@f3>4Jl)|F8W7iG=LER`iiEIaFo=2FD&&eufGQ$=i7sEWR(iL@&Y74@cz z{_clFr!vG*7urL`U{5E}c&5ne?I5XK+`iFCQnA?9_duqxZ1H(u274epgJC3GhK1f_5SYT-1rLKj?v0XJ)BQy4sg1z3jn@C|-~#=}@VWI_QHffXvC3aa5G rG(j`8K|6H8Rp^16Fa)D80e4{vreW5jVeA speed <300.0>; - turn aimx1 to x-axis <0> speed <300.0>; - curPitch1 = 0; - curHead1 = 0; weaponReady = FALSE; - isAiming = 0; + // walk believed angles back in sync with the pieces (instant reset desyncs mid-restore aim) + set-signal-mask SIGNAL_AIM1; + var done; + var delta; + while (!done){ + done = TRUE; + delta = WRAPDELTA(0 - curHead1); + if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretY / 30))) { + curHead1 = curHead1 + SIGN(delta) * (Weapon1TurretY / 30); + done = FALSE; + }else{ + curHead1 = 0; + } + delta = WRAPDELTA(0 - curPitch1); + if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretX / 30))) { + curPitch1 = curPitch1 + SIGN(delta) * (Weapon1TurretX / 30); + done = FALSE; + }else{ + curPitch1 = 0; + } + turn aimy1 to y-axis curHead1 speed Weapon1TurretY; + turn aimx1 to x-axis <0> - curPitch1 speed Weapon1TurretX; + if (!done) { + sleep 1; + } + } + isAiming = 0; } SetStunned(State) { @@ -1035,38 +1057,44 @@ QueryWeapon1(pieceIndex) } AimWeapon1(heading, pitch) -{ +{ signal SIGNAL_AIM1; set-signal-mask SIGNAL_AIM1; var canShoot; var delta; - + var nextHead; + var nextPitch; + while (!canShoot){ canShoot = TRUE; delta = WRAPDELTA(heading - curHead1); if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretY / 30))) { - curHead1 = curHead1 + SIGN(delta) * (Weapon1TurretY / 30); + nextHead = WRAPDELTA(curHead1 + SIGN(delta) * (Weapon1TurretY / 30)); canShoot = FALSE; }else{ - curHead1 = heading; + nextHead = heading; } delta = WRAPDELTA(pitch - curPitch1); if (ABSOLUTE_GREATER_THAN(delta,(Weapon1TurretX / 30))) { - curPitch1 = curPitch1 + SIGN(delta) * (Weapon1TurretX / 30); + nextPitch = WRAPDELTA(curPitch1 + SIGN(delta) * (Weapon1TurretX / 30)); canShoot = FALSE; }else{ - curPitch1 = pitch; + nextPitch = pitch; } - - turn aimy1 to y-axis curHead1 speed Weapon1TurretY; - turn aimx1 to x-axis <0> - curPitch1 speed Weapon1TurretX; + + turn aimy1 to y-axis nextHead speed Weapon1TurretY; + turn aimx1 to x-axis <0> - nextPitch speed Weapon1TurretX; //get PRINT(get GAME_FRAME, curHead1, heading); + // turn > sleep > commit: a preempting re-aim kills the thread at its sleep, + // cancelling the uncommitted step, so belief cannot outrun the pieces if (!canShoot) { sleep 1; } + curHead1 = nextHead; + curPitch1 = nextPitch; } start-script RestoreAfterDelay(); diff --git a/scripts/Units/corak.cob b/scripts/Units/corak.cob index 10f892fed0fbcbaf3791df943afe4b74e3da4994..e2eb19186c1cf81c885607ef7d6c13fb6a8f5e64 100644 GIT binary patch delta 935 zcmZY7ZAep57zglk&h8e9)?Kz-iPLExhOPOUkb<~`K@kM*o4qjlpcH+O*zOd=EFpW- z(kG&z$V}|b3OiH|=O#u$R2GCr2tgqG5J4|Qg7u;QnbU&q<;T7EoacG&InTX`U*da* z$*nQQ9IzTHiWw`wRUN;hj0MLT`wVV8SHu|W4Xhcd%?Q$9&`Q@rSS{9>AVgKUZQVSmXXThw5>*{rSF5L@m zdW||HcZtg?;Qis)olQAl%-~XKXDLfE%j72Y%QE`=J>QLl^YJoj41yaooIw_b>-vVFCCAV=G`K j*dPa-;DTbPglh1D4|aeb8Xy3TaBzZ=H)@m7u_EyYcPilA delta 682 zcmZ9}Ur19?90%~<`Ry7Ni>@(;{n=dhVA&kg64pzMtQcVrn$m)ZDbYYkqO~nIAz4M( zgS8F~RzaE(716NM-~B6s@F{(nJs5@$HDU!tFA_rayOyxy( zYD8p*HIQ@>)nhisi7e4)2;_*Ic)p7{8L&)v&8M1uGekmX{cq4gx={!N*8bnCHbSx^ z)F6GKT4@fMlZThfuMvl6m8+sJWhtujPn#@9BRp!8M^1N2cSMuzVSOzd2ffSm+cqLz z`k3Zsy-JFKAkW%l-+&ReRR0TpSPBZ+9XTNVF>}oVR*X9tcO$pZjo0>d=+A zCP>YszL~^!_2$~N#+3$@y7`X93U&NeT;uUAs&eNTbGT7;-rH*uR+WBmmigIMWzQs7 zbf~e3AoHA4t$gfa(WQ1geJ?oEq^!^T1kX3g#EaLWR_)2I7NSLV=h{Sz{n_@N)p!$nBJC_IELOu{tG z!AJNGi(n}b)fFf}%_zLEAN+6>`XLN)NWd^$f~#-?#^F9>;0a{m6->eulwcO-3na&i JHOg0P Date: Wed, 26 Aug 2026 14:44:51 -0400 Subject: [PATCH 09/17] guard nuke_controller error after lua gracefully degrades tbl access (#8814) --- luarules/gadgets/pve_nuke_controller.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/luarules/gadgets/pve_nuke_controller.lua b/luarules/gadgets/pve_nuke_controller.lua index ac9fc0e1476..23ba3102caa 100644 --- a/luarules/gadgets/pve_nuke_controller.lua +++ b/luarules/gadgets/pve_nuke_controller.lua @@ -174,7 +174,8 @@ function gadget:GameFrame(frame) for nukeID, cooldown in pairs(aliveNukeLaunchers) do if cooldown <= now then local targetID = targetUnits[math.random(1, targetCount)] - if targetID and GetUnitTeam(targetID) ~= GetUnitTeam(nukeID) then + local nukeTeam = GetUnitTeam(nukeID) + if targetID and nukeTeam and GetUnitTeam(targetID) ~= nukeTeam then local x, y, z = GetUnitPosition(targetID) if x and z then x = x + math.random(-1024, 1024) From 83238e36265753f0a6db0345bdfe5140f761d232 Mon Sep 17 00:00:00 2001 From: lawnchaiir Date: Wed, 26 Aug 2026 14:48:23 -0400 Subject: [PATCH 10/17] Adding support for the Tab key to cycle through chat channels while text input is empty (#8429) Added support for a hotkey to cycle through chat channels similarly to clicking the channel button. --- luaui/Widgets/gui_chat.lua | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/luaui/Widgets/gui_chat.lua b/luaui/Widgets/gui_chat.lua index 34229172ffd..12b2c041475 100644 --- a/luaui/Widgets/gui_chat.lua +++ b/luaui/Widgets/gui_chat.lua @@ -3563,6 +3563,16 @@ function widget:TextInput(char) -- if it isn't working: chobby probably hijacked end end +function widget:cycleInputMode(reverse) + local inputModeOrder = mySpec and {'', 's:'} or {'', 's:', 'a:'} + local modeIndex = table.getKeyOf(inputModeOrder, inputMode) or 1 + local direction = reverse and -1 or 1 + + inputMode = inputModeOrder[(modeIndex - 1 + direction) % #inputModeOrder + 1] + + updateTextInputDlist = true +end + function widget:KeyRelease(key, mods, label, unicode, scanCode) -- Since we grab the keyboard, we need to specify a KeyRelease to make sure other release actions can be triggered if @@ -3926,7 +3936,9 @@ function widget:KeyPress(key, mods, isRepeat, label, unicode, scanCode, actions) autocomplete(inputText, true) elseif key == 9 and inputMode ~= "label" then -- TAB inputSelectionStart = nil - if autocompleteText and autocompleteWords[1] then + if inputText == '' and not isRepeat then + self:cycleInputMode(shift) + elseif autocompleteText and autocompleteWords[1] then inputText = utf8.sub(inputText, 1, inputTextPosition) .. autocompleteText .. utf8.sub(inputText, inputTextPosition + 1) @@ -4050,14 +4062,7 @@ function widget:MousePress(x, y, button) state.inputButtonRect[4] ) then - if inputMode == "a:" then - inputMode = "" - elseif inputMode == "s:" then - inputMode = mySpec and "" or "a:" - else - inputMode = "s:" - end - updateTextInputDlist = true + self:cycleInputMode() return true end From 99fd0d549273a242c30f1b7f09bc71dd3064892b Mon Sep 17 00:00:00 2001 From: "Clarence \"Sparr\" Risher" Date: Wed, 26 Aug 2026 14:50:30 -0400 Subject: [PATCH 11/17] Annotate the GG APIs (#8853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmmyLua annotations for the gadget API surface (`GG.*`), including descriptions, function parameters they take, and the shapes they return. Covers 29 gadgets, plus one new type file for various ways to represent a position that will be used across future PRs. Annotation only — no runtime behavior changes. --- luarules/gadgets/api_build_blocking.lua | 10 +++ luarules/gadgets/api_pbr_enabler.lua | 4 + luarules/gadgets/cus_gl4.lua | 20 ++++- luarules/gadgets/game_apm_broadcast.lua | 2 + luarules/gadgets/game_quick_start.lua | 3 + luarules/gadgets/game_startbox_config.lua | 17 +++++ luarules/gadgets/game_team_death_effect.lua | 12 +++ luarules/gadgets/game_team_power_watcher.lua | 59 ++++++++++++++- .../gfx_environmental_lightning_gl4.lua | 12 +++ luarules/gadgets/gfx_fire_gl4.lua | 74 +++++++++++++++++-- luarules/gadgets/gfx_fire_smoke_gl4.lua | 73 +++++++++++++----- luarules/gadgets/gfx_flamethrower_gl4.lua | 6 ++ luarules/gadgets/gfx_projectile_dispatch.lua | 20 +++++ luarules/gadgets/gfx_raptor_scum_gl4.lua | 19 ++++- luarules/gadgets/gfx_tree_feller.lua | 5 ++ luarules/gadgets/gfx_unit_shield_effects.lua | 12 +++ .../gadgets/gfx_water_type_overlay_state.lua | 11 +++ luarules/gadgets/scav_spawn_effect.lua | 14 ++++ luarules/gadgets/unit_attributes.lua | 11 ++- luarules/gadgets/unit_capture_decay.lua | 3 + luarules/gadgets/unit_cloak.lua | 8 ++ .../unit_collision_damage_behavior.lua | 3 + luarules/gadgets/unit_corpse_link.lua | 4 + .../gadgets/unit_instant_self_destruct.lua | 3 + luarules/gadgets/unit_reactive_armor.lua | 6 ++ luarules/gadgets/unit_shield_behaviour.lua | 19 ++++- luarules/gadgets/unit_target_on_the_move.lua | 28 +++++++ luarules/gadgets/unit_wanted_speed.lua | 5 ++ luarules/gadgets/unit_zombies.lua | 42 ++++++++++- types/Position.lua | 19 +++++ 30 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 types/Position.lua diff --git a/luarules/gadgets/api_build_blocking.lua b/luarules/gadgets/api_build_blocking.lua index 9d2aa29f972..236012a3867 100644 --- a/luarules/gadgets/api_build_blocking.lua +++ b/luarules/gadgets/api_build_blocking.lua @@ -296,6 +296,11 @@ if gadgetHandler:IsSyncedCode() then gadgetHandler:RemoveChatAction("buildunblock") end + ---Marks a unit definition as unbuildable by a team for the given reason. + ---Reasons stack: the unit stays blocked until every reason is removed. + ---@param unitDefID UnitDefID + ---@param teamID TeamID + ---@param reasonKey string Identifier for why the unit is blocked, e.g. "terrain_water". function GG.BuildBlocking.AddBlockedUnit(unitDefID, teamID, reasonKey) local blockedUnitDefs = teamBlockedUnitDefs[teamID] if not blockedUnitDefs then @@ -308,6 +313,11 @@ if gadgetHandler:IsSyncedCode() then notifyUnitBlocked(unitDefID, teamID, unitReasons) end + ---Removes one block reason from a unit definition for a team. + ---@param unitDefID UnitDefID + ---@param teamID TeamID + ---@param reasonKey string Identifier previously passed to `AddBlockedUnit`. + ---@return boolean removed `true` if that reason was set and has been cleared. function GG.BuildBlocking.RemoveBlockedUnit(unitDefID, teamID, reasonKey) local blockedUnitDefs = teamBlockedUnitDefs[teamID] if not blockedUnitDefs then diff --git a/luarules/gadgets/api_pbr_enabler.lua b/luarules/gadgets/api_pbr_enabler.lua index 224422e6d03..ff9b542e1db 100644 --- a/luarules/gadgets/api_pbr_enabler.lua +++ b/luarules/gadgets/api_pbr_enabler.lua @@ -39,10 +39,14 @@ if not gadgetHandler:IsSyncedCode() then --unsynced gadget local ENVLUT_SAMPLES -- number of cubemap samples + ---Returns the name of the generated BRDF lookup texture. + ---@return string? texName `nil` if the lookup table failed to generate. local function GetBrdfTexture() return brdfLut:GetTexture() end + ---Returns the name of the generated IBL environment lookup texture. + ---@return string? texName `nil` if the lookup table failed to generate. local function GetEnvTexture() return envLut:GetTexture() end diff --git a/luarules/gadgets/cus_gl4.lua b/luarules/gadgets/cus_gl4.lua index 0d91c5ed0ab..02e3aa5f8bc 100644 --- a/luarules/gadgets/cus_gl4.lua +++ b/luarules/gadgets/cus_gl4.lua @@ -244,6 +244,10 @@ local objectDefToUniformBin = {} -- maps unitDefID/featuredefID to a uniform bin -- objectDefs are negative for features -- objectIDs are negative for features too +---Maps an object definition to the uniform bin its draw call belongs to. +---@param objectDefID (UnitDefID|-FeatureDefID)? Positive for unitDefIDs, negative for featureDefIDs. +---@param reason string? Label used in debug output when no bin is found. +---@return string uniformBinID Falls back to `'otherunit'` for unmapped definitions. local function GetUniformBinID(objectDefID, reason) if objectDefID and objectDefToUniformBin[objectDefID] then return objectDefToUniformBin[objectDefID] @@ -478,7 +482,9 @@ local unitDrawBins = nil -- this also controls whether cusgl4 is on at all! local objectIDtoDefID = {} -local shaders = {} -- double nested table of {drawflag : {"units":shaderID}} +---Compiled shaders, keyed by draw flag and then by material name. +---@type table> +local shaders = {} local modelsVertexVBO = nil local modelsIndexVBO = nil @@ -550,6 +556,10 @@ end local featuresDefsWithAlpha = {} local unitDefsUseSkinning = {} +---Returns the compiled shader used to draw an object in a given draw pass. +---@param drawPass integer Draw flag of the current pass. +---@param objectDefID (UnitDefID|-FeatureDefID)? Positive for unitDefIDs, negative for featureDefIDs. +---@return LuaShader|false shader `false` when `objectDefID` is `nil`. local function GetShader(drawPass, objectDefID) if objectDefID == nil then return false @@ -569,6 +579,10 @@ local function GetShader(drawPass, objectDefID) end end +---Returns the name of the shader used to draw an object in a given draw pass. +---@param drawPass integer Draw flag of the current pass. +---@param objectDefID (UnitDefID|-FeatureDefID)? Positive for unitDefIDs, negative for featureDefIDs. +---@return "unit"|"unitskinning"|"tree"|"feature"|false shaderName `false` when `objectDefID` is `nil`. local function GetShaderName(drawPass, objectDefID) -- this function does 2 table lookups, could get away with just one. if objectDefID == nil then @@ -603,6 +617,10 @@ local function SetFixedStatePost(drawPass, shaderID) end end +---Uploads the draw pass, clip plane, and uniform bin values to the active shader. +---@param drawPass integer Draw flag of the current pass. +---@param shaderID integer GL program id of the currently bound shader. +---@param uniformBinID string Key into `uniformBins`, as returned by `GetUniformBinID`. local function SetShaderUniforms(drawPass, shaderID, uniformBinID) -- Cache uniform locations per-shader to avoid repeated gl.GetUniformLocation calls every frame local locCache = uniformLocCache[shaderID] diff --git a/luarules/gadgets/game_apm_broadcast.lua b/luarules/gadgets/game_apm_broadcast.lua index dafdc0ecfb7..1e38b9a3a3e 100644 --- a/luarules/gadgets/game_apm_broadcast.lua +++ b/luarules/gadgets/game_apm_broadcast.lua @@ -31,6 +31,8 @@ if gadgetHandler:IsSyncedCode() then end end + ---Excludes the unit's next order from the team's APM count for one game frame. + ---@param unitID UnitID local function addSkipOrder(unitID) ignoreUnits[unitID] = gameFrame + 1 end diff --git a/luarules/gadgets/game_quick_start.lua b/luarules/gadgets/game_quick_start.lua index d5748cbf5d9..fbbb82bcd09 100644 --- a/luarules/gadgets/game_quick_start.lua +++ b/luarules/gadgets/game_quick_start.lua @@ -165,6 +165,9 @@ local buildsInProgress = {} GG.quick_start = {} +---Moves tracked state from one commander unit to another. +---@param oldUnitID UnitID? +---@param newUnitID UnitID? function GG.quick_start.transferCommanderData(oldUnitID, newUnitID) if oldUnitID and newUnitID and spValidUnitID(oldUnitID) and spValidUnitID(newUnitID) then buildsInProgress[newUnitID] = buildsInProgress[oldUnitID] diff --git a/luarules/gadgets/game_startbox_config.lua b/luarules/gadgets/game_startbox_config.lua index d60a9467f67..921f2089be3 100644 --- a/luarules/gadgets/game_startbox_config.lua +++ b/luarules/gadgets/game_startbox_config.lua @@ -51,6 +51,12 @@ function gadget:Initialize() GG.startBoxConfig = startBoxConfig GG.startBoxConfigSource = configSource + ---Tests a map position against an allyteam's polygonal start box. + ---@param x number + ---@param z number + ---@param allyTeamID AllyTeamID + ---@return boolean? inside `nil` when no polygon config exists; the caller should + ---fall back to the engine's axis-aligned start box. GG.IsInsideStartbox = function(x, z, allyTeamID) if not isExplicitConfig then return nil -- caller should fall back to engine AABB @@ -64,6 +70,13 @@ function gadget:Initialize() return PolygonLib.PointInStartbox(x, z, entry) end + ---Returns the axis-aligned bounding box of an allyteam's start box polygons. + ---@param allyTeamID AllyTeamID + ---@return number? xmin `nil` when no polygon config exists; the caller should + ---fall back to the engine's axis-aligned start box. + ---@return number? zmin + ---@return number? xmax + ---@return number? zmax GG.GetStartboxBounds = function(allyTeamID) if not isExplicitConfig then return nil -- caller should fall back to engine AABB @@ -77,6 +90,10 @@ function gadget:Initialize() return PolygonLib.GetStartboxBounds(entry) end + ---Returns the raw start box polygons configured for an allyteam. + ---@param allyTeamID AllyTeamID + ---@return Position2D[][]? boxes Array of polygons, each an array of `{x, z}` vertex + ---pairs. `nil` when no polygon config exists. GG.GetStartboxPolygons = function(allyTeamID) if not isExplicitConfig then return nil diff --git a/luarules/gadgets/game_team_death_effect.lua b/luarules/gadgets/game_team_death_effect.lua index 4297e35b33a..1732f53eff3 100644 --- a/luarules/gadgets/game_team_death_effect.lua +++ b/luarules/gadgets/game_team_death_effect.lua @@ -44,6 +44,12 @@ local function getSqrDistance(x1, z1, x2, z2) return (dx * dx) + (dz * dz) end +---Neutralizes a team's units and queues them to explode +---@param teamID TeamID +---@param originX number? Wave epicentre; when omitted the death frames are randomized. +---@param originZ number? Wave epicentre; when omitted the death frames are randomized. +---@param attackerUnitID UnitID? Credited as the killer of the destroyed units. +---@param periodMult number? Scales how long the wave takes. Defaults to `1`. local function wipeoutTeam(teamID, originX, originZ, attackerUnitID, periodMult) -- only teamID is required wipedoutTeams[teamID] = Spring.GetGameFrame() periodMult = periodMult or 1 @@ -101,6 +107,12 @@ local function wipeoutTeam(teamID, originX, originZ, attackerUnitID, periodMult) GG.maxDeathFrame = GG.maxDeathFrame and math.max(GG.maxDeathFrame, maxDeathFrame) or maxDeathFrame -- storing frame of total unit wipeout end +---Wipes out every team in an allyteam, shortening the wave when few units remain. +---@param allyTeamID AllyTeamID +---@param attackerUnitID UnitID? Credited as the killer of the destroyed units. +---@param originX number? Wave epicentre; when omitted the death frames are randomized. +---@param originZ number? Wave epicentre; when omitted the death frames are randomized. +---@param periodMult number? Scales how long the wave takes. Defaults to `1`. local function wipeoutAllyTeam(allyTeamID, attackerUnitID, originX, originZ, periodMult) -- only allyTeamID is required -- xmas gadget uses this (to prevent creating xmasballs) if not _G.destroyingTeam then diff --git a/luarules/gadgets/game_team_power_watcher.lua b/luarules/gadgets/game_team_power_watcher.lua index d627bf0bde8..187ee8d5b05 100644 --- a/luarules/gadgets/game_team_power_watcher.lua +++ b/luarules/gadgets/game_team_power_watcher.lua @@ -15,6 +15,11 @@ if not gadgetHandler:IsSyncedCode() then return end +---A team paired with a power value, as returned by the highest/lowest lookups. +---@class PowerLib.TeamPower +---@field teamID TeamID? `nil` when no team qualified. +---@field power number + local teamIsOverPoweredRatio = 1.25 local alliesAreWinningRatio = 1.25 local mathHuge = math.huge @@ -105,11 +110,14 @@ local function isPlayerTeam(teamID) end -- Returns the power of the input teamID as a number. +---@param teamID TeamID +---@return number power local function teamPower(teamID) return teamPowers[teamID] end -- Returns the total power of all non scavenger/raptor teams as a number. +---@return number power local function totalPlayerTeamsPower() local totalPower = 0 @@ -123,6 +131,7 @@ local function totalPlayerTeamsPower() end -- Returns the highest non scavenger/raptor team power as a table {teamID, power}. +---@return PowerLib.TeamPower local function highestPlayerTeamPower() local highestPower = 0 local highestTeamID = nil @@ -140,6 +149,7 @@ local function highestPlayerTeamPower() end -- Returns the average of all non scavenger/raptor teams as a number. +---@return number power `0` when no team has any power. local function averagePlayerTeamPower() local totalPower = 0 local teamCount = 0 @@ -156,6 +166,7 @@ local function averagePlayerTeamPower() end -- Returns the lowest non scavenger/raptor team power as a table {teamID, power}. +---@return PowerLib.TeamPower local function lowestPlayerTeamPower() local lowestPower = mathHuge local lowestTeamID = nil @@ -173,6 +184,7 @@ local function lowestPlayerTeamPower() end -- Returns the highest non AI/scavenger/raptor team power as a table {teamID, power}. +---@return PowerLib.TeamPower local function highestHumanTeamPower() local highestPower = 0 local highestTeamID = nil @@ -190,6 +202,7 @@ local function highestHumanTeamPower() end -- Returns the average of all non AI/scavenger/raptor teams as a number. +---@return number power `0` when no team has any power. local function averageHumanTeamPower() local totalPower = 0 local teamCount = 0 @@ -206,6 +219,7 @@ local function averageHumanTeamPower() end -- Returns the lowest non AI/scavenger/raptor team power as a table {teamID, power}. +---@return PowerLib.TeamPower local function lowestHumanTeamPower() local lowestPower = mathHuge local lowestTeamID = nil @@ -223,6 +237,9 @@ local function lowestHumanTeamPower() end -- Returns the highest team power of the allies belonging to input team or allyID. Returns as a table {teamID, power}. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return PowerLib.TeamPower local function highestAlliedTeamPower(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local highestPower = 0 @@ -241,6 +258,9 @@ local function highestAlliedTeamPower(teamID, allyID) end -- Returns the average of all allies of the input teamID or allyID. Returns a number. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return number power `0` when no allied team has any power. local function averageAlliedTeamPower(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local totalPower = 0 @@ -258,6 +278,9 @@ local function averageAlliedTeamPower(teamID, allyID) end -- Returns the lowest of the teamID's allies or allyID's power as a table {teamID, power}. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return PowerLib.TeamPower local function lowestAlliedTeamPower(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local lowestPower = mathHuge @@ -275,7 +298,11 @@ local function lowestAlliedTeamPower(teamID, allyID) return { teamID = lowestTeamID, power = lowestPower } end +---@alias PowerLib.TechLevel number Fractional tech level between `0.5` and `4.5`. + -- Take an input of a power value and return an estimated tech level number. +---@param power number +---@return PowerLib.TechLevel local function techGuesstimate(power) local techLevel = 0 for _, threshold in ipairs(powerThresholds) do @@ -290,6 +317,8 @@ local function techGuesstimate(power) end -- Takes an input teamID return an estimated tech level number. +---@param teamID TeamID +---@return PowerLib.TechLevel local function teamTechGuesstimate(teamID) local totalPower = teamPowers[teamID] local techLevel = 0 @@ -305,6 +334,7 @@ local function teamTechGuesstimate(teamID) end -- Calculate all average powers of all non scavenger/raptor teams and return an estimated tech level number. +---@return PowerLib.TechLevel local function averagePlayerTechGuesstimate() local totalPower = 0 local teamCount = 0 @@ -331,6 +361,7 @@ local function averagePlayerTechGuesstimate() end -- Compares average powers of all non AI/scavenger/raptor teams return an estimated tech level number. +---@return PowerLib.TechLevel local function averageHumanTechGuesstimate() local totalPower = 0 local teamCount = 0 @@ -357,6 +388,9 @@ local function averageHumanTechGuesstimate() end -- Compare average powers of all allied teams of the input teamID or allyID and return an estimated tech level number. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return PowerLib.TechLevel local function averageAlliedTechGuesstimate(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local totalPower = 0 @@ -384,6 +418,8 @@ local function averageAlliedTechGuesstimate(teamID, allyID) end -- Returns the highest power achieved by the the input teamID as a number. +---@param teamID TeamID +---@return number power `0` when the team has never held any power. local function teamPeakPower(teamID) for id, power in pairs(peakTeamPowers) do if id == teamID then @@ -394,6 +430,7 @@ local function teamPeakPower(teamID) end -- Returns the total peak power achieved by all non scavenger/raptor teams as a number. +---@return number power local function totalPlayerPeakPower() local totalPeakPower = 0 @@ -407,6 +444,7 @@ local function totalPlayerPeakPower() end -- Returns the highest power achieved by any non scavenger/raptor team as a table {teamID, power}. +---@return PowerLib.TeamPower local function highestPlayerPeakPower() local highestPower = 0 local highestTeamID = nil @@ -424,6 +462,9 @@ local function highestPlayerPeakPower() end -- Returns the highest power achieved by any non scavenger/raptor team on the same team as the input teamID or allyID as a table {teamID, power}. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return PowerLib.TeamPower local function highestAlliedPeakPower(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local highestPower = 0 @@ -442,6 +483,7 @@ local function highestAlliedPeakPower(teamID, allyID) end -- Returns the average of all the peak powers achieved by non AI/scavenger/raptor teams as a number. +---@return number power `0` when no team has any peak power. local function averageHumanPeakPower() local totalPower = 0 local teamCount = 0 @@ -458,6 +500,9 @@ local function averageHumanPeakPower() end -- Returns the average of all the peak powers achieved by allied teams of the input teamID or allyID as a number. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return number power `0` when no allied team has any peak power. local function averageAlliedPeakPower(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local totalPower = 0 @@ -475,6 +520,8 @@ local function averageAlliedPeakPower(teamID, allyID) end -- Returns the ratio number of the input teamID compared to the average of all players. +---@param teamID TeamID +---@return number ratio The team's power divided by the player average; `0` if the average is `0`. local function teamComparedToAveragedPlayers(teamID) local totalPower = 0 local teamCount = 0 @@ -494,6 +541,9 @@ local function teamComparedToAveragedPlayers(teamID) end -- Returns boolean true if the input teamID is considered significantly more powerful by the API. Second argument allows user-defined ratio. +---@param teamID TeamID +---@param marginRatio number? Ratio above the player average that counts as overpowered. Defaults to `1.25`. +---@return boolean local function isTeamOverPowered(teamID, marginRatio) marginRatio = marginRatio or teamIsOverPoweredRatio local totalPower = 0 @@ -518,6 +568,9 @@ local function isTeamOverPowered(teamID, marginRatio) end -- Returns the ratio number of the input teamID's allies or allyID compared to the average of all player allies. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@return number ratio The allyteam's power divided by the allyteam average; `0` if the average is `0`. local function alliesComparedToAverage(teamID, allyID) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) local allyPowers = {} @@ -545,7 +598,11 @@ local function alliesComparedToAverage(teamID, allyID) return ratio end --- Returns boolean true if the input teamID's allies or allyID is considered significantly more powerful by the API. Third argument allows user-defined ratio. +-- Returns `true` if the input teamID's allies or allyID is considered significantly more powerful by the API. Third argument allows user-defined ratio. +---@param teamID TeamID? Only used to look up `allyID`; ignored when `allyID` is given. +---@param allyID AllyTeamID? +---@param marginRatio number? Ratio above the allyteam average that counts as winning. Defaults to `1.25`. +---@return boolean local function isAllyTeamWinning(teamID, allyID, marginRatio) allyID = allyID or select(6, Spring.GetTeamInfo(teamID)) marginRatio = marginRatio or alliesAreWinningRatio diff --git a/luarules/gadgets/gfx_environmental_lightning_gl4.lua b/luarules/gadgets/gfx_environmental_lightning_gl4.lua index e43cd468ad4..9a85c2aa69c 100644 --- a/luarules/gadgets/gfx_environmental_lightning_gl4.lua +++ b/luarules/gadgets/gfx_environmental_lightning_gl4.lua @@ -40,7 +40,19 @@ if gadgetHandler:IsSyncedCode() then local spGetTeamInfo = Spring.GetTeamInfo function gadget:Initialize() + ---Spawns a configured lightning effect at a world position. + ---@param configName string Key of the lightning config to spawn. + ---@param x number + ---@param y number + ---@param z number + ---@param sizeScale number? Defaults to `1.0`. + ---@param intensityScale number? Defaults to `1.0`. + ---@param ownerTeamID TeamID? Team the strike belongs to, used to derive the + ---owning allyteam for visibility. Defaults to no owner. GG.SpawnEnvironmentalLightning = function(configName, x, y, z, sizeScale, intensityScale, ownerTeamID) + -- This guard is necessary as long as some call sites pass in nil coordinates, + -- e.g. unsanitized Spring.GetUnitPosition for a dead unit + ---@diagnostic disable-next-line: unnecessary-if if not configName or not x or not y or not z then return end diff --git a/luarules/gadgets/gfx_fire_gl4.lua b/luarules/gadgets/gfx_fire_gl4.lua index bca08f7492d..4ff113b9a0f 100644 --- a/luarules/gadgets/gfx_fire_gl4.lua +++ b/luarules/gadgets/gfx_fire_gl4.lua @@ -1449,13 +1449,56 @@ end -------------------------------------------------------------------------------- -- Public spawn helpers (also drive GG.Fire) -------------------------------------------------------------------------------- + +---A live fire emitter. Returned by the spawn functions and accepted by `GG.Fire.StopFire`. +---@class GG.Fire.Handle +---@field unitID UnitID? Unit the emitter was spawned for, if any. +---@field mappedUnit UnitID? Unit the emitter position follows, if any. +---@field keepAfterUnitGone true? Keep burning after the followed unit disappears. +---@field scavenger true? Set when the effect uses the scavenger palette. +---@field x number +---@field y number +---@field z number +---@field yOffset number +---@field radius number +---@field scale number +---@field intensity number +---@field lightIntensity number +---@field lightRadiusMult number +---@field fireRate number Particles per frame; `0` disables flames. +---@field smokeRate number Particles per frame; `0` disables smoke. +---@field emberRate number Particles per frame; `0` disables embers. +---@field fireEnd integer Game frame at which flames stop spawning. +---@field smokeEnd integer Game frame at which smoke stops spawning. +---@field emberEnd integer Game frame at which embers stop spawning. + +---Options accepted by `GG.Fire.SpawnFire`. +---@class GG.Fire.SpawnOpts +---@field duration integer? Frames of flame. Defaults to the configured unit fire duration. +---@field smokeDuration integer? Frames of smoke. Defaults to `duration` plus the configured extra. +---@field emberDuration integer? Frames of embers. Defaults to `duration`. +---@field unitID UnitID? Unit to associate the emitter with. +---@field scavenger boolean? Use the scavenger palette. +---@field yOffset number? Defaults to `0`. +---@field radius number? Defaults to `14`. +---@field scale number? Defaults to `1.0`. +---@field intensity number? Defaults to `1.0`. +---@field lightIntensity number? Defaults to `1.0`. +---@field lightRadiusMult number? Defaults to `1.0`. +---@field fire boolean? Set to `false` to suppress flames. +---@field fireRate number? Particles per frame. +---@field smoke boolean? Set to `false` to suppress smoke. +---@field smokeRate number? Particles per frame. +---@field embers boolean? Set to `false` to suppress embers. +---@field emberRate number? Particles per frame. + -- Spawn a free-standing fire effect. Returns an opaque handle usable with --- StopFire. opts (all optional): --- duration, smokeDuration, emberDuration (frames) --- radius, scale, intensity --- unitID (attach to a unit; position follows it) --- yOffset (vertical emit offset, default 0 / unit param) --- fire, smoke, embers (booleans to enable each, default all true) +-- StopFire. +---@param x number? +---@param y number? +---@param z number? +---@param opts GG.Fire.SpawnOpts? +---@return GG.Fire.Handle local function spawnFire(x, y, z, opts) opts = opts or {} local now = cachedGameFrame @@ -1961,12 +2004,18 @@ function gadget:Initialize() gadgetHandler:AddSyncAction("treefire_fade", syncTreeFireFade) GG.Fire = { - -- SpawnFire(x, y, z, opts) -> handle. See spawnFire above for opts. + ---Spawns a fire emitter at a world position. + ---@param x number? + ---@param y number? + ---@param z number? + ---@param opts GG.Fire.SpawnOpts? + ---@return GG.Fire.Handle SpawnFire = function(x, y, z, opts) return spawnFire(x, y, z, opts) end, -- StopFire(handle): immediately stop spawning new particles (existing -- ones fade out naturally). + ---@param handle GG.Fire.Handle StopFire = function(handle) if type(handle) == "table" then handle.fireEnd = cachedGameFrame @@ -1976,6 +2025,9 @@ function gadget:Initialize() end, -- AddUnitFire(unitID[, durationFrames]): attach a burning effect that -- follows the unit. Refreshes the timer if already burning. + ---@param unitID UnitID + ---@param durationFrames integer? Frames of flame. Defaults to the configured unit fire duration. + ---@return GG.Fire.Handle? handle `nil` if the unit no longer exists. AddUnitFire = function(unitID, durationFrames) local udid = Spring.GetUnitDefID(unitID) if udid then @@ -1983,15 +2035,23 @@ function gadget:Initialize() end end, -- SpawnWreck(x, y, z[, scale]): short fire + long smoke at a position. + ---@param x number + ---@param y number + ---@param z number + ---@param scale number? Defaults to `1.0`. + ---@return GG.Fire.Handle? handle `nil` when the position is not visible to the local player. SpawnWreck = function(x, y, z, scale) return spawnVisibleWreckageFire(x, y, z, scale) end, + ---@return integer count Particles currently alive. GetParticleCount = function() return particleVBO and particleVBO.usedElements or 0 end, + ---@return integer count Particle budget for the whole system. GetMaxParticles = function() return MAX_PARTICLES end, + ---@return table config The live tuning table, including nested colour tables; treat as read-only. GetConfig = function() return CONFIG end, diff --git a/luarules/gadgets/gfx_fire_smoke_gl4.lua b/luarules/gadgets/gfx_fire_smoke_gl4.lua index cb6e368c18a..8276e8f7551 100644 --- a/luarules/gadgets/gfx_fire_smoke_gl4.lua +++ b/luarules/gadgets/gfx_fire_smoke_gl4.lua @@ -1671,6 +1671,11 @@ end -- Other gadgets can call these to spawn fire/smoke effects -------------------------------------------------------------------------------- +---Starts the trailing fire/smoke effect for an aircraft that has begun crashing. +---Does nothing if the effect system is unavailable or the unit is already tracked. +---@param unitID UnitID +---@param unitDefID UnitDefID +---@param teamID TeamID local function apiCrashingAircraft(unitID, unitDefID, teamID) if not particleVBO then return @@ -1704,24 +1709,29 @@ local function apiCrashingAircraft(unitID, unitDefID, teamID) crashingAircraftCount = crashingAircraftCount + 1 end --- Add a generic point emitter at a fixed position. +---Parameters accepted by `GG.FireSmoke.AddPointEmitter`. +---@class GG.FireSmoke.EmitterParams +---@field x number World position X coordinate. +---@field y number? World position Y coordinate. Defaults to the ground height at `x`, `z`. +---@field z number? World position Z coordinate. Defaults to `0`. +---@field duration integer? Frames the emitter lives for. `0` = permanent. Defaults to `300`. +---@field sizeScale number? Particle size multiplier. Defaults to `1.0`. +---@field fireIntensity number? `0` emits smoke only. `[0,1)` range of fire chance/brightness. Defaults to `0`. +---@field spawnCount integer? Particles emitted per interval. Defaults to `POINT_SPAWN_COUNT``. +---@field spawnInterval integer? Frames between spawns. Defaults to `POINT_SPAWN_INTERVAL``. +---@field priority integer? One of the `PRIORITY_ESSENTIAL` `_NORMAL` `_COSMETIC` constants. Defaults to `PRIORITY_NORMAL`. +---@field smokeSizeMult number? Multiplier on spoke particle size. Defaults to `1.0`. +---@field smokeLifeMult number? Multiplier on smoke particle lifetime. Defaults to `1.0`. +---@field smokeAlpha number? Base smoke alpha (opacity). Defaults to `1.0`. +---@field fireSizeMult number? Multiplier on fire particle size. Defaults to `1.0`. +---@field fireLifeMult number? Multiplier on fire particle lifetime. Defaults to `1.0`. +---@field posSpread number? Random position jitter applied per particle in elmos. Defaults to `POINT_POS_SPREAD` +---@field velocityScale number? Multiplier on particle velocity. Defaults to `1.0`. + +---Registers a long-lived point emitter that keeps spawning particles. -- Returns emitterID (use to remove later) or nil if VBO not ready. --- --- params table fields (all optional except x,y,z): --- x, y, z - world position (required) --- duration - emit for this many frames, 0 = permanent (default: 300) --- sizeScale - particle size multiplier (default: 1.0) --- fireIntensity - 0 = smoke only, 0-1 = fire chance/brightness (default: 0) --- spawnCount - smoke particles per interval (default: POINT_SPAWN_COUNT) --- spawnInterval - frames between spawns (default: POINT_SPAWN_INTERVAL) --- priority - PRIORITY_ESSENTIAL/NORMAL/COSMETIC (default: NORMAL) --- smokeSizeMult - multiplier on smoke particle size (default: 1.0) --- smokeLifeMult - multiplier on smoke lifetime (default: 1.0) --- smokeAlpha - base smoke alpha (default: 1.0) --- fireSizeMult - multiplier on fire particle size (default: 1.0) --- fireLifeMult - multiplier on fire particle lifetime (default: 1.0) --- posSpread - random position offset radius in elmos (default: POINT_POS_SPREAD) --- velocityScale - multiplier on particle velocity (default: 1.0) +---@param params GG.FireSmoke.EmitterParams +---@return integer? emitterID `nil` when the effect system is unavailable or `params` or `params.x` is missing. local function apiAddPointEmitter(params) if not particleVBO then return nil @@ -1758,7 +1768,9 @@ local function apiAddPointEmitter(params) return id end --- Remove a point emitter by ID (returned from AddPointEmitter) +---Removes a point emitter. +---@param emitterID integer? As returned by `AddPointEmitter`. +---@return boolean removed `false` if no such emitter exists. local function apiRemoveEmitter(emitterID) if emitterID and pointEmitters[emitterID] then pointEmitters[emitterID] = nil @@ -1769,6 +1781,11 @@ local function apiRemoveEmitter(emitterID) end -- Update emitter position (for moving sources) +---@param emitterID integer As returned by `AddPointEmitter`. +---@param x number +---@param y number +---@param z number +---@return boolean updated `false` if no such emitter exists. local function apiUpdateEmitterPos(emitterID, x, y, z) local emitter = pointEmitters[emitterID] if not emitter then @@ -1780,8 +1797,18 @@ local function apiUpdateEmitterPos(emitterID, x, y, z) return true end --- Spawn a single particle directly (one-shot, no emitter tracking) --- priority: PRIORITY_ESSENTIAL/NORMAL/COSMETIC (default: NORMAL) +---Spawns a single one-shot particle with no emitter tracking. +---@param px number +---@param py number +---@param pz number +---@param vx number? Defaults to `0`. +---@param vy number? Defaults to `0`. +---@param vz number? Defaults to `0`. +---@param size number? Defaults to `2`. +---@param isFireType boolean? Spawn a flame particle instead of smoke. +---@param lifetime integer? Frames the particle lives for. Defaults to `60`. +---@param alpha number? Defaults to `1.0`. +---@param priority integer? One of the `PRIORITY_*` constants. Defaults to `PRIORITY_NORMAL`. local function apiSpawnParticle(px, py, pz, vx, vy, vz, size, isFireType, lifetime, alpha, priority) if not particleVBO then return @@ -1791,14 +1818,20 @@ local function apiSpawnParticle(px, py, pz, vx, vy, vz, size, isFireType, lifeti end -- Query current state +---@return integer count Particles currently alive. local function apiGetParticleCount() return particleVBO and particleVBO.usedElements or 0 end +---@return number count Particle budget for the whole system. local function apiGetMaxParticles() return MAX_PARTICLES end +---Returns the wind vector the particle simulation is currently using. +---@return number windX +---@return number windZ +---@return number windStrength local function apiGetWindState() return windX, windZ, windStrength end diff --git a/luarules/gadgets/gfx_flamethrower_gl4.lua b/luarules/gadgets/gfx_flamethrower_gl4.lua index 0702b2bf315..21f6c1e2589 100644 --- a/luarules/gadgets/gfx_flamethrower_gl4.lua +++ b/luarules/gadgets/gfx_flamethrower_gl4.lua @@ -2060,15 +2060,21 @@ function gadget:Initialize() end GG.Flamethrower = { + ---@return integer count Particles currently alive. GetParticleCount = function() return particleVBO and particleVBO.usedElements or 0 end, + ---@return integer count Particle budget for the whole system. GetMaxParticles = function() return CONFIG.maxParticles end, + ---@return table config The live tuning table, including nested colour tables; treat as read-only. GetConfig = function() return CONFIG end, + ---Reports whether this gadget draws the flame effect for a weapon. + ---@param weaponDefID WeaponDefID + ---@return boolean IsTracked = function(weaponDefID) return weaponConfigs[weaponDefID] ~= nil end, diff --git a/luarules/gadgets/gfx_projectile_dispatch.lua b/luarules/gadgets/gfx_projectile_dispatch.lua index 04bdee9964d..eb99db88eea 100644 --- a/luarules/gadgets/gfx_projectile_dispatch.lua +++ b/luarules/gadgets/gfx_projectile_dispatch.lua @@ -213,6 +213,13 @@ end -------------------------------------------------------------------------------- -- Public API -------------------------------------------------------------------------------- + +---Registers a consumer of one of the shared projectile scans. The dispatcher runs +---each scan at most once per tick and hands every subscriber its filtered matches. +---@param name string Label used in debug output. +---@param defIDSet table? Set of weaponDefIDs to keep; `nil` matches every projectile. +---@param scanID 1|2|3 One of `SCAN_VISIBLE`, `SCAN_MAP_WEAPONS` or `SCAN_MAP_PIECES`. +---@return integer? handle `nil` when `scanID` is invalid. local function Subscribe(name, defIDSet, scanID) local subs = subscribersByScan[scanID] if not subs then @@ -234,6 +241,10 @@ local function Subscribe(name, defIDSet, scanID) return nextHandle end +---Returns this subscriber's matching projectiles, refreshing the scan if stale. +---@param handle integer As returned by `Subscribe`. +---@return ProjectileID[]? projectileIDs `nil` when the handle is unknown. +---@return integer count Number of valid entries in `projectileIDs`. local function GetMatches(handle) local sub = subscribers[handle] if not sub then @@ -245,6 +256,11 @@ local function GetMatches(handle) return sub.matches, sub.matchCount end +---As `GetMatches`, but also returns the weaponDefID of each matched projectile. +---@param handle integer As returned by `Subscribe`. +---@return ProjectileID[]? projectileIDs `nil` when the handle is unknown. +---@return WeaponDefID[]? weaponDefIDs Parallel to `projectileIDs`. +---@return integer count Number of valid entries in both arrays. local function GetMatchesWithDefIDs(handle) local sub = subscribers[handle] if not sub then @@ -256,6 +272,10 @@ local function GetMatchesWithDefIDs(handle) return sub.matches, sub.matchDefIDs, sub.matchCount end +---Returns the unfiltered result of a scan, refreshing it if stale. +---@param scanID 1|2|3 One of `SCAN_VISIBLE`, `SCAN_MAP_WEAPONS` or `SCAN_MAP_PIECES`. +---@return ProjectileID[]? projectileIDs `nil` when `scanID` is invalid. +---@return integer count Number of valid entries in `projectileIDs`. local function GetScan(scanID) local state = scanState[scanID] if not state then diff --git a/luarules/gadgets/gfx_raptor_scum_gl4.lua b/luarules/gadgets/gfx_raptor_scum_gl4.lua index 038cbc9ab30..94aa49d3fcd 100644 --- a/luarules/gadgets/gfx_raptor_scum_gl4.lua +++ b/luarules/gadgets/gfx_raptor_scum_gl4.lua @@ -138,7 +138,12 @@ if gadgetHandler:IsSyncedCode() then end end - -- This checks whether the unit is under any scum + ---Tests whether a map position is covered by raptor scum. + ---Underwater scum is ignored for surface units, and positions outside the map never match. + ---@param unitx number + ---@param unity number? Height of the tested object. Defaults to `1`; values above `-1` skip underwater scum. + ---@param unitz number + ---@return integer? scumID `nil` when the position is not inside any scum. local function IsPosInScum(unitx, unity, unitz) -- out of bounds check, no scum outside of map bounds if unitx < 0 or unitz < 0 or unitx > mapSizeX or unitz > mapSizeZ then @@ -173,8 +178,11 @@ if gadgetHandler:IsSyncedCode() then return nil end - GG.IsPosInRaptorScum = IsPosInScum --(x,y,z) + GG.IsPosInRaptorScum = IsPosInScum + ---Picks a scum patch at random. + ---@param startID integer? Scum to start iterating from, so repeated calls can spread out. + ---@return integer? scumID `nil` when no scum exists. local function GetRandomScumID(startID) if numscums < 1 then return @@ -190,8 +198,11 @@ if gadgetHandler:IsSyncedCode() then return scumID end - GG.GetRandomScumID = GetRandomScumID -- Returns nil or scumID + GG.GetRandomScumID = GetRandomScumID + ---Picks a random map position inside a random scum patch, staying clear of the map edge. + ---@return number? x `nil` when no scum exists or no valid position was found. + ---@return number? z local function GetRandomPositionInScum() local scumID = GetRandomScumID() if not scumID then @@ -215,7 +226,7 @@ if gadgetHandler:IsSyncedCode() then return px, pz end - GG.GetRandomPositionInScum = GetRandomPositionInScum -- Returns nil or (X, Z) + GG.GetRandomPositionInScum = GetRandomPositionInScum local function UpdateBins(scumID, removeScum) local scumTable = scums[scumID] diff --git a/luarules/gadgets/gfx_tree_feller.lua b/luarules/gadgets/gfx_tree_feller.lua index 295fc817607..d0d7379a195 100644 --- a/luarules/gadgets/gfx_tree_feller.lua +++ b/luarules/gadgets/gfx_tree_feller.lua @@ -328,6 +328,11 @@ if gadgetHandler:IsSyncedCode() then return fixedCount end + ---Fells every tree within 125 elmos of a position, as used by the commander spawn blast. + ---@param spawnx number + ---@param spawny number + ---@param spawnz number + ---@return integer? aborted Returns `0` and stops early if a geothermal feature is in range. local function ComSpawnDefoliate(spawnx, spawny, spawnz) local blasted_trees = Spring.GetFeaturesInCylinder(spawnx, spawnz, 125) diff --git a/luarules/gadgets/gfx_unit_shield_effects.lua b/luarules/gadgets/gfx_unit_shield_effects.lua index 2a43102165a..c1573b4aa53 100644 --- a/luarules/gadgets/gfx_unit_shield_effects.lua +++ b/luarules/gadgets/gfx_unit_shield_effects.lua @@ -433,6 +433,18 @@ end local DECAY_FACTOR = 0.2 local MIN_DAMAGE = 3 +---A single recent impact on a unit's shield. +---@class ShieldHit +---@field hitFrame integer Game frame the damage was last accumulated. +---@field dmg number Decaying damage value driving the effect's brightness. +---@field aoe number Radius of the visual ripple. +---@field x number +---@field y number +---@field z number + +---Returns the decaying list of recent impacts on a unit's shield. +---@param unitID UnitID +---@return ShieldHit[]? hits `nil` when the unit has no shield or has not been hit. local function GetShieldHitPositions(unitID) local unitData = IterableMap.Get(shieldUnits, unitID) return (((unitData and unitData.hitData) and unitData.hitData) or nil) diff --git a/luarules/gadgets/gfx_water_type_overlay_state.lua b/luarules/gadgets/gfx_water_type_overlay_state.lua index 3a71d9c51c6..787a8b11112 100644 --- a/luarules/gadgets/gfx_water_type_overlay_state.lua +++ b/luarules/gadgets/gfx_water_type_overlay_state.lua @@ -226,22 +226,32 @@ function gadget:Initialize() minGroundHeight = select(3, spGetGroundExtremes()) GG.WaterTypeOverlay = { + ---@return boolean active Whether a hazardous water overlay is currently applied. isActive = function() return active end, + ---@return "lava"|"acid"|nil typeName `nil` while the overlay is inactive. getActiveType = function() return activeType end, + ---@return number level Current absolute world height of the overlay surface. getLevel = function() return currentLevel end, + ---@return number level Offset from the base water plane the overlay eases toward. getTargetLevel = function() return targetLevel end, + ---Sets the height the overlay surface eases toward, relative to the base water plane. + ---@param level number setLevel = function(level) targetLevel = level end, + ---Turns the overlay on and starts damaging units in it. + ---@param typeName "lava"|"acid" + ---@return boolean started `false` when `typeName` is not a supported overlay type. activate = function(typeName) + ---@diagnostic disable-next-line: unnecessary-if if typeName ~= "lava" and typeName ~= "acid" then return false end @@ -252,6 +262,7 @@ function gadget:Initialize() activeType = typeName return true end, + ---Turns the overlay off and restores any unit state it changed. deactivate = function() if active then restoreAllUnits() diff --git a/luarules/gadgets/scav_spawn_effect.lua b/luarules/gadgets/scav_spawn_effect.lua index 19ba8a62c5e..8857eb22fcf 100644 --- a/luarules/gadgets/scav_spawn_effect.lua +++ b/luarules/gadgets/scav_spawn_effect.lua @@ -51,6 +51,8 @@ if gadgetHandler:IsSyncedCode() then -- Synced end end + ---Plays the scavenger spawn explosion at a unit, sized from its unit definition. + ---@param unitID UnitID function ScavengersSpawnEffectUnitID(unitID) local posx, posy, posz = Spring.GetUnitPosition(unitID) local unitDefID = Spring.GetUnitDefID(unitID) @@ -59,12 +61,19 @@ if gadgetHandler:IsSyncedCode() then -- Synced end GG.ScavengersSpawnEffectUnitID = ScavengersSpawnEffectUnitID + ---Plays the scavenger spawn explosion at a position, sized from a unit definition. + ---@param unitDefID UnitDefID + ---@param posx number + ---@param posy number + ---@param posz number function ScavengersSpawnEffectUnitDefID(unitDefID, posx, posy, posz) local size = getUnitSize(unitDefID) Spring.SpawnCEG("scav-spawnexplo-" .. size, posx, posy, posz, 0, 0, 0) end GG.ScavengersSpawnEffectUnitDefID = ScavengersSpawnEffectUnitDefID + ---Plays the scavenger spawn explosion at a feature, sized from its feature definition. + ---@param featureID FeatureID function ScavengersSpawnEffectFeatureID(featureID) local posx, posy, posz = Spring.GetFeaturePosition(featureID) local featureDefID = Spring.GetFeatureDefID(featureID) @@ -73,6 +82,11 @@ if gadgetHandler:IsSyncedCode() then -- Synced end GG.ScavengersSpawnEffectFeatureID = ScavengersSpawnEffectFeatureID + ---Plays the scavenger spawn explosion at a position, sized from a feature definition. + ---@param featureDefID FeatureDefID + ---@param posx number + ---@param posy number + ---@param posz number function ScavengersSpawnEffectFeatureDefID(featureDefID, posx, posy, posz) local size = getFeatureSize(featureDefID) Spring.SpawnCEG("scav-spawnexplo-" .. size, posx, posy, posz, 0, 0, 0) diff --git a/luarules/gadgets/unit_attributes.lua b/luarules/gadgets/unit_attributes.lua index 8b1a860a9de..9868e7fc794 100644 --- a/luarules/gadgets/unit_attributes.lua +++ b/luarules/gadgets/unit_attributes.lua @@ -444,6 +444,11 @@ end --Spring.Echo("Hornet debug UpdateUnitAttributes defined") +---Recomputes a unit's speed, turn rate, acceleration, reload, economy and build +---multipliers from the `GG.att_*` tables and applies them to the engine. +---Call after changing any `GG.att_*` entry for the unit. +---@param unitID UnitID +---@param frame integer? Game frame to attribute the change to. Defaults to the current frame. function UpdateUnitAttributes(unitID, frame) if not spValidUnitID(unitID) then removeUnit(unitID) @@ -648,7 +653,11 @@ function UpdateUnitAttributes(unitID, frame) end end --- Whatever sets this should call UpdateUnitAttributes frames afterwards too +---Controls whether the engine may coast this unit rather than braking it, +---which the attribute system otherwise overrides. +---Whatever sets this should call UpdateUnitAttributes frames afterwards too. +---@param unitID UnitID +---@param allowed boolean? local function SetAllowUnitCoast(unitID, allowed) allowUnitCoast[unitID] = allowed end diff --git a/luarules/gadgets/unit_capture_decay.lua b/luarules/gadgets/unit_capture_decay.lua index 81e526150a0..76709539a3f 100644 --- a/luarules/gadgets/unit_capture_decay.lua +++ b/luarules/gadgets/unit_capture_decay.lua @@ -57,6 +57,9 @@ function gadget:AllowUnitCaptureStep(builderID, builderTeam, unitID, unitDefID, return true end +---Starts tracking a unit so its partial capture progress decays over time. +---Does nothing if the unit is already tracked. +---@param unitID UnitID function addUnitToCaptureDecay(unitID) if not unitsWithCaptureProgress[unitID] then unitsWithCaptureProgress[unitID] = { previousCaptureProgress = 0, ticksFromLastCapture = 999 } diff --git a/luarules/gadgets/unit_cloak.lua b/luarules/gadgets/unit_cloak.lua index 58a66bc97b4..f6906e7115d 100644 --- a/luarules/gadgets/unit_cloak.lua +++ b/luarules/gadgets/unit_cloak.lua @@ -65,6 +65,10 @@ for udid, ud in pairs(UnitDefs) do end end +---Forces a unit to decloak and blocks it from recloaking for a while. +---Repeated calls extend the block rather than stacking. +---@param unitID UnitID +---@param duration integer? Frames to stay decloaked. Defaults to the gadget's decloak time. function PokeDecloakUnit(unitID, duration) if recloakUnit[unitID] then recloakUnit[unitID] = duration or DEFAULT_DECLOAK_TIME @@ -176,6 +180,10 @@ function gadget:AllowUnitDecloak(unitID, objectID, weaponID) recloakFrame[unitID] = currentFrame + DEFAULT_DECLOAK_TIME end +---Sets the unit's desired cloak state, updating its command description to match. +---Does nothing for dead or missing units. +---@param unitID UnitID? +---@param state 0|1 `1` to request cloaking, `0` to request decloaking. local function SetWantedCloaked(unitID, state) if not unitID or spGetUnitIsDead(unitID) then return diff --git a/luarules/gadgets/unit_collision_damage_behavior.lua b/luarules/gadgets/unit_collision_damage_behavior.lua index 6f1fd6d495c..951629a36cb 100644 --- a/luarules/gadgets/unit_collision_damage_behavior.lua +++ b/luarules/gadgets/unit_collision_damage_behavior.lua @@ -293,6 +293,9 @@ function gadget:GameFrame(frame) gameFrame = frame end +---Enables or disables collision-damage velocity tracking for a unit. +---@param unitID UnitID +---@param enabled boolean? Pass `false` to stop tracking; any other value starts it. local function setVelocityControl(unitID, enabled) if enabled == false then launchedUnits[unitID] = nil diff --git a/luarules/gadgets/unit_corpse_link.lua b/luarules/gadgets/unit_corpse_link.lua index 059e35b5045..004774d64f1 100644 --- a/luarules/gadgets/unit_corpse_link.lua +++ b/luarules/gadgets/unit_corpse_link.lua @@ -40,6 +40,10 @@ local function GetFeatureResurrectDefID(featureID) return unitDef.id end +---Returns the unitID the corpse feature was created from, consuming the link so +---each corpse resolves at most once. +---@param featureID FeatureID +---@return UnitID? unitID `nil` when no unit is linked to this corpse. local function GetCorpsePriorUnitID(featureID) -- Technically features can rez into something else than they died as, -- or even be rezzable without ever dying, but let's assume they don't diff --git a/luarules/gadgets/unit_instant_self_destruct.lua b/luarules/gadgets/unit_instant_self_destruct.lua index da0e90690cf..25a3ca824c1 100644 --- a/luarules/gadgets/unit_instant_self_destruct.lua +++ b/luarules/gadgets/unit_instant_self_destruct.lua @@ -43,6 +43,9 @@ end local toDestroy = {} local toDestroyCount = 0 +---Queues a unit to be destroyed on the next game frame. +---@param unitID UnitID +---@param skipChecks boolean? Destroy even while the unit is stunned. local function QueueUnitDestruction(unitID, skipChecks) if skipChecks or not spGetUnitIsStunned(unitID) then toDestroyCount = toDestroyCount + 1 diff --git a/luarules/gadgets/unit_reactive_armor.lua b/luarules/gadgets/unit_reactive_armor.lua index 46701057ab2..8ae364b0b00 100644 --- a/luarules/gadgets/unit_reactive_armor.lua +++ b/luarules/gadgets/unit_reactive_armor.lua @@ -360,6 +360,10 @@ end -- Lifecycle ---Damages or repairs a unit's reactive armor without changing unit health. +---@param unitID UnitID +---@param damage number Positive damages the armor, negative repairs it. +---@return boolean changed `false` when the unit has no reactive armor, its armor is +---already broken, or a repair was requested while already at full armor. GG.AddReactiveArmorDamage = function(unitID, damage) local unitDefData = armoredUnitDefs[spGetUnitDefID(unitID)] if unitDefData and damage ~= 0 then @@ -370,6 +374,8 @@ GG.AddReactiveArmorDamage = function(unitID, damage) end ---Get the current armor health remaining of a unit with reactive armor. +---@param unitID UnitID +---@return number? health `nil` when the unit has no reactive armor or it is broken. GG.GetReactiveArmorHealth = function(unitID) return unitArmorHealth[unitID] end diff --git a/luarules/gadgets/unit_shield_behaviour.lua b/luarules/gadgets/unit_shield_behaviour.lua index d80e4c751a3..794904cc65d 100644 --- a/luarules/gadgets/unit_shield_behaviour.lua +++ b/luarules/gadgets/unit_shield_behaviour.lua @@ -55,8 +55,12 @@ if Spring.GetModOptions().experimentalshields:find("bounce") then end ---Pass a `weaponDefID` instead of a `damage` for shield damage to be determined for you. + ---@param shieldUnitID UnitID + ---@param damage number + ---@param weaponDefID nil ---@return boolean exhausted The damage was mitigated, in full, by the shield. ---@return number damageDone The amount of damage done to the targeted shield. + ---@overload fun(shieldUnitID: UnitID, damage: nil, weaponDefID: WeaponDefID): boolean, number local function addEngineShieldDamage(shieldUnitID, damage, weaponDefID) local state, power = spGetUnitShieldState(shieldUnitID) @@ -132,6 +136,9 @@ if Spring.GetModOptions().experimentalshields:find("bounce") then registerScriptedShieldEntry(projectileTbl, callback) end + ---Stand-in for the sphere queries, which engine shields do not support. + ---@return UnitID[] shieldUnits Always empty. + ---@return integer count Always `0`. local function getEmptyResultSet() return {}, 0 end @@ -143,10 +150,14 @@ if Spring.GetModOptions().experimentalshields:find("bounce") then GG.Shields.RegisterShieldPreDamaged = registerShieldPreDamaged GG.Shields.GetUnitShieldState = spGetUnitShieldState -- FIXME: The shields api does not have full coverage for engine/bounce shields. + ---Not supported for engine shields; always returns nothing. + ---@type fun(shieldUnitID: UnitID): number?, number?, number?, number? GG.Shields.GetUnitShieldPosition = function() end GG.Shields.GetShieldUnitsInSphere = getEmptyResultSet GG.Shields.GetBlockingShieldUnits = getEmptyResultSet GG.Shields.GetCoveringShieldUnits = getEmptyResultSet + ---Not supported for engine shields; always returns `false`. + ---@type fun(x: number, y: number, z: number, shieldUnitID: UnitID): boolean GG.Shields.IsInShield = function() return false end -- unfortunate @@ -770,6 +781,7 @@ end -- Gadget interface methods ---------------------------------------------------- +---@param shieldUnitID UnitID ---@return integer state 0 := DISABLED, 1 := ENABLED ---@return number shieldHealthRemaining including the (hidden) damage done this frame so far local function getUnitShieldState(shieldUnitID) @@ -790,8 +802,12 @@ local function getUnitShieldState(shieldUnitID) end ---Pass a `weaponDefID` instead of a `damage` for shield damage to be determined for you. +---@param shieldUnitID UnitID +---@param damage number +---@param weaponDefID nil ---@return boolean exhausted The damage was mitigated, in full, by the shield. ---@return number damageDone The amount of damage done to the targeted shield. +---@overload fun(shieldUnitID: UnitID, damage: nil, weaponDefID: WeaponDefID): boolean, number local function addCustomShieldDamage(shieldUnitID, damage, weaponDefID) local state, power = getUnitShieldState(shieldUnitID) -- because the unit can be dead @@ -816,6 +832,7 @@ local function addCustomShieldDamage(shieldUnitID, damage, weaponDefID) return false, 0 end +---@param shieldUnitID UnitID ---@return number? x xyz, emitter point of the shield weapon ---@return number? y ---@return number? z @@ -890,8 +907,8 @@ end ---@param x number ---@param y number ---@param z number ----@param allyTeam integer The ally team for the incoming damage source. ---@param radius number? Additive with the radius of the target shield (default := `0.01`) +---@param allyTeam AllyTeamID? The ally team for the incoming damage source. ---@param onlyAlive boolean? Navigate the rework's one-frame delay on shield effects by excluding recently-dead units (default := `false`) ---@return integer[] shieldUnits ---@return integer count diff --git a/luarules/gadgets/unit_target_on_the_move.lua b/luarules/gadgets/unit_target_on_the_move.lua index b0f85147f35..7eb7daf5cce 100644 --- a/luarules/gadgets/unit_target_on_the_move.lua +++ b/luarules/gadgets/unit_target_on_the_move.lua @@ -528,16 +528,33 @@ if gadgetHandler:IsSyncedCode() then refreshSendData(unitID, unitData, minIndex) end + ---A single entry in a unit's target queue, as tracked on the synced side. + ---@class UnitTargetEntry + ---@field target UnitID|Position3D Either a target unitID or a `{x, y, z}` ground position. + ---@field alwaysSeen boolean? Target does not need to stay in sensor range to be kept. + ---@field ignoreStop boolean? Target survives a Stop command. + ---@field userTarget boolean? Target was set by the player rather than by Lua. + ---@field sent boolean? Target has already been pushed to the unit's weapons. + + ---Returns the unit's currently active target. + ---@param unitID UnitID + ---@return UnitID|Position3D|nil target A unitID, a `{x, y, z}` ground position, or `nil` when untargeted. function GG.GetUnitTarget(unitID) local unitData = activeTargets[unitID] local targetData = unitData and unitData.targets[unitData.currentIndex] return targetData and targetData.target end + ---Returns the unit's whole target queue. + ---@param unitID UnitID + ---@return UnitTargetEntry[]? targets `nil` when the unit has no targets. function GG.GetUnitTargetList(unitID) return activeTargets[unitID] and activeTargets[unitID].targets end + ---Returns the position in the target queue that is currently active. + ---@param unitID UnitID + ---@return integer? index `nil` when the unit has no targets. function GG.GetUnitTargetIndex(unitID) return activeTargets[unitID] and activeTargets[unitID].currentIndex end @@ -1050,10 +1067,21 @@ else -- UNSYNCED gadgetHandler:RemoveSyncAction("failCommand") end + ---An entry in the unsynced mirror of a unit's target queue, kept for drawing. + ---@class UnitTargetEntryUnsynced + ---@field target UnitID|Position3D Either a target unitID or a `{x, y, z}` ground position. + ---@field userTarget boolean? Target was set by the player rather than by Lua. + + ---Returns the unsynced mirror of the unit's target queue. + ---@param unitID UnitID + ---@return table? targets `nil` when the unit has no known targets. function GG.getUnitTargetList(unitID) return targetList[unitID] and targetList[unitID].targets end + ---Returns the position in the unsynced target queue that is currently active. + ---@param unitID UnitID + ---@return integer? index `nil` when the unit has no known targets. function GG.getUnitTargetIndex(unitID) return targetList[unitID] and targetList[unitID].currentIndex end diff --git a/luarules/gadgets/unit_wanted_speed.lua b/luarules/gadgets/unit_wanted_speed.lua index a60449ea7a2..0dff30931e4 100644 --- a/luarules/gadgets/unit_wanted_speed.lua +++ b/luarules/gadgets/unit_wanted_speed.lua @@ -121,6 +121,11 @@ local function SetUnitWantedSpeed(unitID, unitDefID, wantedSpeed, forceUpdate) end ---this makes no sense, why does this chain exist +---Reapplies the unit's wanted max speed to its move type, +---e.g. after the engine has reset it. +---@param unitID UnitID +---@param unitDefID UnitDefID +---@param clearWanted boolean? Drop the remembered wanted speed instead of restoring it. function GG.ForceUpdateWantedMaxSpeed(unitID, unitDefID, clearWanted) SetUnitWantedSpeed( unitID, diff --git a/luarules/gadgets/unit_zombies.lua b/luarules/gadgets/unit_zombies.lua index 40614a4161d..192f5f6f9c5 100644 --- a/luarules/gadgets/unit_zombies.lua +++ b/luarules/gadgets/unit_zombies.lua @@ -61,6 +61,9 @@ local harderTechToRezPowerSpeeds = { [4.5] = 130, } +---One of the zombie difficulty presets, matching the keys of `zombieModeConfigs`. +---@alias ZombieMode "normal"|"hard"|"nightmare"|"akumu" + local zombieModeConfigs = { normal = { techToRezPowerSpeeds = standardTechToRezPowerSpeeds, @@ -96,6 +99,7 @@ local zombieModeConfigs = { }, } +---@type ZombieMode local currentZombieMode = "normal" local currentZombieConfig = zombieModeConfigs.normal @@ -426,8 +430,12 @@ local function updateRezSpeed() rebuildZombieCorpseSpawnDelays() end +---Applies a preset's tuning to the live zombie config, falling back to `normal` +---for an unknown mode. +---@param mode ZombieMode local function applyZombieModeSettings(mode) local config = zombieModeConfigs[mode] + ---@diagnostic disable-next-line: unnecessary-if if not config then config = zombieModeConfigs.normal end @@ -812,6 +820,8 @@ local function spawnZombies(featureID, unitDefID, healthReductionRatio, x, y, z, end end +---Turns a unit into a zombie, swapping it for its `_scav` variant where one exists. +---@param unitID UnitID local function setZombie(unitID) local unitDefID = spGetUnitDefID(unitID) if not unitDefID then @@ -856,6 +866,7 @@ local function clearUnitOrders(unitID) end end +---Clears the queued orders of every tracked zombie. local function clearAllOrders() for zombieID, _ in pairs(zombieWatch) do clearUnitOrders(zombieID) @@ -1230,6 +1241,9 @@ function gadget:UnitPreDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, w end end +---Immediately raises zombies from a corpse feature. Only acts while in idle mode. +---@param featureID FeatureID +---@return boolean spawned `false` when not in idle mode, or the feature is not a zombie corpse. local function createZombieFromFeature(featureID) if isIdleMode then local featureDefID = spGetFeatureDefID(featureID) @@ -1258,6 +1272,7 @@ local function createZombieFromFeature(featureID) return false end +---Queues every corpse currently on the map to raise zombies. local function queueAllCorpsesForSpawning() local features = Spring.GetAllFeatures() for _, featureID in ipairs(features) do @@ -1265,6 +1280,8 @@ local function queueAllCorpsesForSpawning() end end +---Switches all zombies between return-fire with no auto-orders and normal aggression. +---@param enabled boolean `true` to pacify, `false` to restore normal behavior. local function pacifyZombies(enabled) local fireState if enabled then @@ -1282,6 +1299,8 @@ local function pacifyZombies(enabled) end end +---Stops or resumes the automatic orders given to zombies, without changing fire state. +---@param enabled boolean `true` to suspend auto-orders, `false` to resume them. local function suspendAutoOrders(enabled) if enabled then ordersEnabled = false @@ -1317,6 +1336,9 @@ local function fightNearTargets(targetUnits) return true end +---Sends every zombie to fight the units of one team. +---@param teamID TeamID +---@return boolean ordered `false` when the team is dead or has no units. local function aggroTeamID(teamID) clearAllOrders() @@ -1330,6 +1352,9 @@ local function aggroTeamID(teamID) return fightNearTargets(targetUnits) end +---Sends every zombie to fight the units of every team in an allyteam. +---@param allyID AllyTeamID +---@return boolean ordered `false` when the allyteam has no teams or no units. local function aggroAllyID(allyID) clearAllOrders() @@ -1350,6 +1375,7 @@ local function aggroAllyID(allyID) return fightNearTargets(targetUnits) end +---Kills every tracked zombie with environmental damage. local function killAllZombies() for zombieID, zombieData in pairs(zombieWatch) do if spValidUnitID(zombieID) and not Spring.GetUnitIsDead(zombieID) then @@ -1361,6 +1387,9 @@ local function killAllZombies() end end +---Enables or disables raising zombies from corpses automatically. +---Enabling also queues every corpse already on the map. +---@param enabled boolean local function setAutoSpawning(enabled) autoSpawningEnabled = enabled if enabled then @@ -1368,6 +1397,7 @@ local function setAutoSpawning(enabled) end end +---Drops every queued corpse spawn without affecting zombies already raised. local function clearAllZombieSpawns() for featureID in pairs(corpsesData) do clearCorpseRezRulesParam(featureID) @@ -1399,6 +1429,9 @@ local function isAuthorized(playerID) return false end +---Turns each of the given units into a zombie. +---@param unitIDs UnitID[]? +---@return integer converted Number of units that were valid and converted. local function convertUnitsToZombies(unitIDs) if not unitIDs or #unitIDs == 0 then return 0 @@ -1415,6 +1448,8 @@ local function convertUnitsToZombies(unitIDs) return convertedCount end +---Turns every Gaia-owned unit that is not already a zombie into one. +---@return integer converted local function setAllGaiaToZombies() local allUnits = Spring.GetAllUnits() local convertedCount = 0 @@ -1593,7 +1628,11 @@ local function commandClearZombieSpawns(_, line, words, playerID) Spring.SendMessageToPlayer(playerID, "Cleared all queued zombie spawns") end +---Switches the zombie difficulty preset. +---@param mode ZombieMode +---@return boolean applied `false` when `mode` is not a known preset. local function setZombieMode(mode) + ---@diagnostic disable-next-line: unnecessary-if if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then return false end @@ -1637,7 +1676,7 @@ function gadget:Initialize() return end - local initialMode = modOptions.zombies or "normal" + local initialMode = modOptions.zombies --[[@as ZombieMode?]] or "normal" applyZombieModeSettings(initialMode) autoSpawningEnabled = modOptionEnabled and not isIdleMode @@ -1673,6 +1712,7 @@ function gadget:Initialize() GG.Zombies.KillAllZombies = killAllZombies GG.Zombies.ClearAllOrders = clearAllOrders GG.Zombies.SetZombieMode = setZombieMode + ---@return ZombieMode mode The active difficulty preset. GG.Zombies.GetZombieMode = function() return currentZombieMode end diff --git a/types/Position.lua b/types/Position.lua new file mode 100644 index 00000000000..93b47bf4a01 --- /dev/null +++ b/types/Position.lua @@ -0,0 +1,19 @@ +---@meta + +--- Coordinate shapes used across the BAR Lua code. Two forms are in circulation: +--- fixed-length arrays, as the Spring API mostly returns, and named-field tables. +--- Prefer these over `number[]` or `table`, so the language server checks the +--- element count or the field names. +--- +--- These stay aliases rather than numbered-field classes, unlike the heterogeneous +--- tuples elsewhere: `api_object_spotlight` uses `integer|Position3D` as a table +--- KEY type, and a class in key position makes every lookup an undefined-field. + +--- A world position as the array `{x, y, z}`, y being the height. +---@alias Position3D [number, number, number] + +--- A map position as the array `{x, z}`, with the height implied by the terrain. +---@alias Position2D [number, number] + +--- A map position as the named-field table `{x = ..., z = ...}`. +---@alias PositionXZ {x: number, z: number} From b00b169a0e989fe617cf3f716a88f056385d9491 Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 20:54:21 +0200 Subject: [PATCH 12/17] radar ranges preview: also display all allied radar coverage (#8927) --- .../sensor_ranges_radar_preview.frag.glsl | 13 +- .../sensor_ranges_radar_preview.vert.glsl | 54 ++++-- ...or_ranges_radar_preview_coverage.frag.glsl | 9 +- ...sensor_ranges_radar_preview_pass.vert.glsl | 4 +- .../gui_sensor_ranges_radar_preview.lua | 173 ++++++++++++++++-- 5 files changed, 217 insertions(+), 36 deletions(-) diff --git a/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl b/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl index 095b937d2b2..0cb8b6a0c4b 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl @@ -10,8 +10,9 @@ //__DEFINES__ in DataVS { - vec3 localPos; // position on the unit cube - vec4 fx; // coverage, glow, beam, spawn + vec3 localPos; // position on the unit cube + vec4 fx; // coverage, glow, beam, spawn + float previewWeight; // 1 = covered by the previewed radar, 0 = only by other allied radars }; // Occlusion is tested against the deferred g-buffer depths instead of the regular depth buffer, so @@ -31,6 +32,8 @@ out vec4 fragColor; const vec3 baseColor = BASE_COLOR; const vec3 highlightColor = HIGHLIGHT_COLOR; +const vec3 alliedColor = ALLIED_COLOR; +const float alliedAlpha = float(ALLIED_ALPHA); const float baseAlpha = float(BASE_ALPHA); const float lineAlpha = float(LINE_ALPHA); const float depthBias = 1e-6; // window-space depth tolerance (a few elmo far away, sub-elmo up close) @@ -80,11 +83,13 @@ void main() { float beam = fx.z; float spawn = fx.w; - vec3 color = mix(baseColor * shade, highlightColor, glow * 0.55); + // cubes covered only by other allied radars use the muted allied look + vec3 tint = mix(alliedColor, baseColor, previewWeight); + vec3 color = mix(tint * shade, highlightColor, glow * 0.55); color = mix(color, highlightColor, line * 0.6); color += highlightColor * beam * 0.25; float alpha = (baseAlpha + 0.3 * glow) * (0.8 + 0.2 * coverage); - alpha = max(alpha, line * lineAlpha); + alpha = max(alpha, line * lineAlpha) * mix(alliedAlpha, 1.0, previewWeight); fragColor = vec4(color, alpha * spawn); } diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl index a34269e81e3..4d03eb4aa27 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl @@ -23,10 +23,14 @@ uniform vec4 windowParams; // first cube index x, first cube index z, cubes uniform sampler2D heightmapTex; uniform sampler2D coverageTex; +#if ALLIED_COVERAGE +uniform sampler2D radarInfoTex; // $info:radar, R = 1 where any allied radar covers the radar cell +#endif out DataVS { - vec3 localPos; // position on the unit cube, for per-face shading and edge lines - vec4 fx; // coverage, glow, beam, spawn + vec3 localPos; // position on the unit cube, for per-face shading and edge lines + vec4 fx; // coverage, glow, beam, spawn + float previewWeight; // 1 = covered by the previewed radar, 0 = only by other allied radars }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -61,6 +65,7 @@ void cullInstance() { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); localPos = vec3(0.0); fx = vec4(0.0); + previewWeight = 0.0; } void main() { @@ -85,23 +90,41 @@ void main() { int radius = int(lookupParams.z); ivec2 worldCell = ivec2(floor(cellXZ / radarCell)); ivec2 off = worldCell - ivec2(lookupParams.xy); - if (any(greaterThan(abs(off), ivec2(radius)))) { + bool inPreviewDisc = all(lessThanEqual(abs(off), ivec2(radius))); + + // coverage of the previewed radar: R = smoothed coverage, G = boundary factor (covered cell next to an + // uncovered one); texel center = radar cell center, nearest or bilinear depending on the texture's filter + vec2 coverageState = vec2(0.0); + if (inPreviewDisc) { + vec2 coverageUV = (cellXZ / radarCell - lookupParams.xy + float(radius)) / gridParams.y; + coverageState = texture(coverageTex, coverageUV).rg; + } + float coverage = coverageState.r; + +#if ALLIED_COVERAGE + // union of all allied radars from the engine's radar map; cubes covered only by those stay static + float allied = 0.0; + if (all(greaterThanEqual(worldCell, ivec2(0))) && all(lessThan(worldCell, textureSize(radarInfoTex, 0)))) { + allied = step(0.5, texelFetch(radarInfoTex, worldCell, 0).r); + } + float weight = smoothstep(0.0, 0.5, coverage); // how much of the previewed radar's animation applies here + coverage = max(coverage, allied); +#else + if (!inPreviewDisc) { cullInstance(); return; } - // texel center = radar cell center; nearest or bilinear depending on the texture's filter - // R = smoothed coverage, G = boundary factor (covered cell next to an uncovered one) - vec2 coverageUV = (cellXZ / radarCell - lookupParams.xy + float(radius)) / gridParams.y; - vec2 coverageState = texture(coverageTex, coverageUV).rg; - float coverage = coverageState.r; + float weight = 1.0; +#endif float distN = dist / range; float time = animParams.x; - // spawn ripple: an expanding ring raises the cubes when the preview appears; they overshoot, then settle + // spawn ripple: an expanding ring raises the cubes when the preview appears; they overshoot, then settle. + // Cubes of other allied radars simply fade in. float front = animParams.y * spawnSpeed; - float spawn = smoothstep(distN - 0.10, distN + 0.02, front); - float bump = sin(clamp((front - distN) / spawnBump, 0.0, 1.0) * PI); + float spawn = mix(min(animParams.y * 4.0, 1.0), smoothstep(distN - 0.10, distN + 0.02, front), weight); + float bump = sin(clamp((front - distN) / spawnBump, 0.0, 1.0) * PI) * weight; if (coverage < minCoverage || lodScale < 0.02 || spawn < 0.01) { cullInstance(); @@ -113,15 +136,15 @@ void main() { float angle = atan(fromCenter.y, fromCenter.x) / (2.0 * PI) + 0.5; float behind = (1.0 - fract(angle - time * sweepSpeed)) * 360.0; // degrees behind the leading edge float trail = clamp(1.0 - behind / sweepTrail, 0.0, 1.0); - float sweep = trail * trail * sweepStrength; - float beam = (1.0 - smoothstep(0.0, sweepBeam, behind)) * sweepStrength; + float sweep = trail * trail * sweepStrength * weight; + float beam = (1.0 - smoothstep(0.0, sweepBeam, behind)) * sweepStrength * weight; // the main animation: rings travelling outward from the radar - float ring = pow(0.5 + 0.5 * sin(dist * pulseFreq - time * pulseSpeed), pulsePower); + float ring = pow(0.5 + 0.5 * sin(dist * pulseFreq - time * pulseSpeed), pulsePower) * weight; // highlight cells at the coverage boundary (an uncovered radar cell next door) and the outer rim (EDGE_STRENGTH, RIM_STRENGTH) float edge = coverageState.g; - float rim = smoothstep(range - 1.5 * radarCell, range - 0.25 * radarCell, dist); + float rim = smoothstep(range - 1.5 * radarCell, range - 0.25 * radarCell, dist) * weight; float glow = clamp(sweep + beam + edge * edgeStrength + rim * rimStrength + ring * 0.6 * pulseStrength + bump * 0.7, 0.0, 1.5); @@ -155,5 +178,6 @@ void main() { localPos = cubeVertex.xyz; fx = vec4(coverage, glow, beam, spawn); + previewWeight = weight; gl_Position = cameraViewProj * vec4(worldPos, 1.0); } diff --git a/luaui/Shaders/sensor_ranges_radar_preview_coverage.frag.glsl b/luaui/Shaders/sensor_ranges_radar_preview_coverage.frag.glsl index d8a258a948e..a489c7e4953 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview_coverage.frag.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview_coverage.frag.glsl @@ -20,6 +20,9 @@ uniform sampler2D mipHeightTex; // radar-cell heightmap (sensor_ranges_radar_preview_mip.frag.glsl) uniform vec4 losParams; // emitter cell x, emitter cell y, radius in cells, emitter height (bucketed) +// 0: the target texture is the (2 * radius + 1)^2 disc around the emitter (texel = cell offset + radius) +// 1: the target texture is the whole map (texel = absolute radar cell), used to union allied radars +uniform float coverageAbsolute = 0.0; // per first-quadrant cell (y * (radius + 1) + x): (offset, count); then per ray through a cell: (targetX, targetY) layout(std430, binding = 5) buffer RayData { @@ -80,10 +83,14 @@ bool visibleAlongRay(ivec2 target, int steps, int rot, ivec2 base, float losHeig void main() { int radius = int(losParams.z); - ivec2 off = ivec2(gl_FragCoord.xy) - ivec2(radius); ivec2 base = ivec2(losParams.xy); + ivec2 off = ivec2(gl_FragCoord.xy) - ((coverageAbsolute > 0.5) ? base : ivec2(radius)); float losHeight = losParams.w; + if (any(greaterThan(abs(off), ivec2(radius)))) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } if (off == ivec2(0)) { fragColor = vec4(1.0, 0.0, 0.0, 1.0); return; diff --git a/luaui/Shaders/sensor_ranges_radar_preview_pass.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview_pass.vert.glsl index 2ea6dde5b40..6aee1db6259 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview_pass.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview_pass.vert.glsl @@ -12,6 +12,8 @@ layout (location = 0) in vec4 pos; // xy = clip space position, zw = uv (unused) +uniform vec4 passRect = vec4(-1.0, -1.0, 1.0, 1.0); // clip-space rectangle the quad is drawn into (whole target by default) + void main() { - gl_Position = vec4(pos.xy, 0.0, 1.0); + gl_Position = vec4(mix(passRect.xy, passRect.zw, pos.xy * 0.5 + 0.5), 0.0, 1.0); } diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index bc99f5b4b20..7624bed988b 100644 --- a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua +++ b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua @@ -45,6 +45,7 @@ local CUBE_SHAPES = { } local LIFT_PER_DISTANCE = 0.001 -- extra lift per elmo of camera distance, keeps flat shapes above the terrain LOD mesh local COVERAGE_SMOOTH = false -- true: blend coverage between radar cells (prettier), false: exact engine cells (blocky) +local SHOW_ALLIED_COVERAGE = true -- also draw the coverage of all allied radars (from the engine's radar map) while the preview is shown; only the previewed radar animates local COVERAGE_REFRESH_SECONDS = 1.0 -- periodic heightmap/coverage rebuild so terraforming shows up local SMOOTH_RATE = 14 -- 1/s, how fast the cubes follow coverage changes (higher = snappier) local LOD_MAX_INSTANCES = 0 -- > 0: thin the grid to double spacing once more cubes than this are on screen (cubes visibly fill in/out with zoom; try 50000 on integrated graphics) @@ -60,6 +61,9 @@ local hasModelDepth = hasMapDepth and Spring.GetConfigString("AllowDeferredModel local shaderConfig = { TERRAIN_DEPTH_TEST = hasMapDepth and 1 or 0, MODEL_DEPTH_TEST = hasModelDepth and 1 or 0, + ALLIED_COVERAGE = SHOW_ALLIED_COVERAGE and 1 or 0, + ALLIED_COLOR = "vec3(0.35, 0.62, 0.50)", -- cubes covered only by other allied radars + ALLIED_ALPHA = 0.7, -- their opacity relative to the previewed radar's cubes MIN_COVERAGE = 0.04, -- cubes below this (smoothed) coverage are not drawn SWEEP_SPEED = 0.11, -- radar sweep revolutions per second SWEEP_TRAIL = 30.0, -- degrees: the trail fades out this far behind the sweep's leading edge @@ -71,15 +75,15 @@ local shaderConfig = { PULSE_SPEED = 90.0, -- elmos per second the rings travel PULSE_POWER = 4.5, -- higher = narrower rings PULSE_STRENGTH = 1.0, -- how much the rings raise/brighten cubes - EDGE_STRENGTH = 0.15, -- how much cubes at the coverage boundary (next to an uncovered radar cell) brighten; 0 disables - RIM_STRENGTH = 0.25, -- how much the outermost ring of cubes brightens; 0 disables + EDGE_STRENGTH = 0.12, -- how much cubes at the coverage boundary (next to an uncovered radar cell) brighten; 0 disables + RIM_STRENGTH = 0.22, -- how much the outermost ring of cubes brightens; 0 disables TILE_MAX_TILT = 20.0, -- degrees: flat tiles follow the terrain slope up to this angle TILE_CLIFF_START = 35.0, -- degrees: terrain steeper than this starts flattening the tiles again TILE_CLIFF_END = 55.0, -- degrees: terrain steeper than this gets flat tiles (cliffs) BASE_COLOR = "vec3(0.22, 0.85, 0.50)", HIGHLIGHT_COLOR = "vec3(0.65, 1.00, 0.80)", - BASE_ALPHA = 0.5, - LINE_ALPHA = 0.5, -- opacity of the cube edge lines + BASE_ALPHA = 0.55, + LINE_ALPHA = 0.55, -- opacity of the cube edge lines } -- Engine radar model (rts/Sim/Misc/LosHandler.cpp, LosMap.cpp) @@ -99,6 +103,10 @@ local HEIGHT_BUCKET = 2 ^ (RADAR_MIP_LEVEL + 2) -- emitter heights are quantized local CUBES_PER_CELL_EDGE = CUBES_PER_CELL[RADAR_MIP_LEVEL] or math.max(1, math.floor(RADAR_CELL / 13 + 0.5)) local CUBE_SPACING = RADAR_CELL / CUBES_PER_CELL_EDGE -- elmos between cube centers local CUBE_WIDTH = CUBE_FILL * CUBE_SPACING -- elmos +local MAP_CELLS_X = math.floor(Game.mapSizeX / RADAR_CELL) -- radar cells of the whole map +local MAP_CELLS_Z = math.floor(Game.mapSizeZ / RADAR_CELL) +local MAP_CUBES_X = MAP_CELLS_X * CUBES_PER_CELL_EDGE -- cube grid size of the whole map +local MAP_CUBES_Z = MAP_CELLS_Z * CUBES_PER_CELL_EDGE -- Localized functions for performance local mathFloor = math.floor @@ -123,6 +131,14 @@ local spGetCameraPosition = Spring.GetCameraPosition local spGetGroundExtremes = Spring.GetGroundExtremes local spGetViewGeometry = Spring.GetViewGeometry local spGetDrawFrame = Spring.GetDrawFrame +local spGetGlobalLos = Spring.GetGlobalLos +local spGetSpectatingState = Spring.GetSpectatingState +local spGetMyAllyTeamID = Spring.GetMyAllyTeamID +local spGetTeamList = Spring.GetTeamList +local spGetTeamUnits = Spring.GetTeamUnits +local spGetUnitSensorRadius = Spring.GetUnitSensorRadius +local spGetUnitIsActive = Spring.GetUnitIsActive +local spGetUnitIsStunned = Spring.GetUnitIsStunned local LuaShader = gl.LuaShader local InstanceVBOTable = gl.InstanceVBOTable @@ -165,6 +181,10 @@ local TILE_INDEX_COUNT = 6 local mipTex = nil -- radar-cell heightmap of the whole map local mipUpdatedAt = -mathHuge +local alliedTex = nil -- map-wide union of the allied radars' coverage, only used under global LOS / full view +local alliedUpdatedAt = -mathHuge +local alliedRadars = {} -- reused scratch list of { bx, bz, radius, losHeight } +local alliedRadarCount = 0 local sets = {} -- radius in cells -> coverage/state textures and grid dimensions local mousepos = { 0, 0, 0 } local selectedRadarUnitID = false @@ -200,6 +220,8 @@ local coverageShaderCache = { }, uniformFloat = { losParams = { 0, 0, 1, 0 }, + coverageAbsolute = { 0 }, + passRect = { -1, -1, 1, 1 }, }, shaderConfig = shaderConfig, } @@ -227,6 +249,7 @@ local cubeShaderCache = { coverageTex = 1, mapDepths = hasMapDepth and 2 or nil, modelDepths = hasModelDepth and 3 or nil, + radarInfoTex = SHOW_ALLIED_COVERAGE and 4 or nil, }, uniformFloat = { radarcenter_range = { 0, 0, 0, 2000 }, @@ -464,16 +487,18 @@ end local function deleteTextures() for _, set in pairs(sets) do - if set.target then - gl.DeleteTexture(set.target) - end - for i = 1, 2 do - if set.state[i] then - gl.DeleteTexture(set.state[i]) + if set then + if set.target then + gl.DeleteTexture(set.target) + end + for i = 1, 2 do + if set.state[i] then + gl.DeleteTexture(set.state[i]) + end + end + if set.raySSBO then + set.raySSBO:Delete() end - end - if set.raySSBO then - set.raySSBO:Delete() end end sets = {} @@ -481,6 +506,20 @@ local function deleteTextures() gl.DeleteTexture(mipTex) mipTex = nil end + if alliedTex then + gl.DeleteTexture(alliedTex) + alliedTex = nil + end +end + +-- coverage/ray-table set for a radius in cells, created on demand (allied radar units of any size) +local function getSet(radiusCells) + local set = sets[radiusCells] + if set == nil then + set = makeSet(radiusCells) or false + sets[radiusCells] = set + end + return set or nil end local function initgl4() @@ -535,6 +574,13 @@ local function initgl4() goodbye("Failed to create the radar preview heightmap texture") return false end + if SHOW_ALLIED_COVERAGE then + alliedTex = makeDataTexture(MAP_CELLS_X, MAP_CELLS_Z, GL_R16F, GL.NEAREST) + if not alliedTex then + goodbye("Failed to create the allied radar coverage texture") + return false + end + end for _, def in pairs(radarDefs) do if not sets[def.radiusCells] then @@ -639,6 +685,9 @@ local function getCubeWindow(set, bx, bz, camX, camY, camZ) local radius = set.radius local x0, x1 = (bx - radius) * n, (bx + radius + 1) * n - 1 local z0, z1 = (bz - radius) * n, (bz + radius + 1) * n - 1 + if SHOW_ALLIED_COVERAGE then -- allied coverage can be anywhere on the map; uncovered cubes exit the vertex shader early + x0, x1, z0, z1 = 0, MAP_CUBES_X - 1, 0, MAP_CUBES_Z - 1 + end local minX, maxX, minZ, maxZ = getScreenFootprint(camX, camY, camZ) if minX then local margin = mathCeil(FOOTPRINT_MARGIN / CUBE_SPACING) @@ -666,6 +715,74 @@ local function getCubeWindow(set, bx, bz, camX, camY, camZ) return x0, z0, cellsX, cellsZ, stride, lodBlend end +-- Allied radar units the engine would give radar coverage (ILosType::UpdateUnit: finished, activated, +-- not stunned, emitter above its own cell), with their emitter cell, radius in cells and bucketed height. +local function collectAlliedRadars() + local count = 0 + local teams = spGetTeamList(spGetMyAllyTeamID()) + for t = 1, #teams do + local units = spGetTeamUnits(teams[t]) + for u = 1, #units do + local unitID = units[u] + local radarRadius = spGetUnitSensorRadius(unitID, "radar") + if radarRadius and radarRadius > 0 and spGetUnitIsActive(unitID) and not spGetUnitIsStunned(unitID) then + local radiusCells = mathFloor(mathFloor(radarRadius / SQUARE_SIZE) / 2 ^ RADAR_MIP_LEVEL) + if radiusCells >= 1 and radiusCells <= MAX_RADIUS_CELLS then + local _, _, _, mx, my, mz = spGetUnitPosition(unitID, true) + local unitDefID = spGetUnitDefID(unitID) + local emitHeight = (unitDefID and UnitDefs[unitDefID].radarEmitHeight) or 0 + local losHeight = (mathFloor(mathMax(my + emitHeight, 0) / HEIGHT_BUCKET) + 0.5) * HEIGHT_BUCKET + local bx, bz = mathFloor(mx / RADAR_CELL), mathFloor(mz / RADAR_CELL) + local cellCenterX = (bx + 0.5) * RADAR_CELL + SQUARE_SIZE * 0.5 + local cellCenterZ = (bz + 0.5) * RADAR_CELL + SQUARE_SIZE * 0.5 + if losHeight > spGetGroundHeight(cellCenterX, cellCenterZ) then + count = count + 1 + local radar = alliedRadars[count] + if not radar then + radar = {} + alliedRadars[count] = radar + end + radar.bx, radar.bz, radar.radius, radar.losHeight = bx, bz, radiusCells, losHeight + end + end + end + end + end + alliedRadarCount = count +end + +-- Renders the union of the collected radars' coverage into alliedTex (one texel per radar cell): each +-- radar draws just its disc's rectangle with the exact coverage shader, MAX-blended over the others. +local function drawAlliedUnion() + gl.Clear(GL.COLOR_BUFFER_BIT, 0, 0, 0, 0) + gl.Blending(GL.ONE, GL.ONE) + gl.BlendEquation(GL.MAX) + coverageShader:Activate() + coverageShader:SetUniform("coverageAbsolute", 1) + local sx, sz = 2 / MAP_CELLS_X, 2 / MAP_CELLS_Z + for i = 1, alliedRadarCount do + local radar = alliedRadars[i] + local set = getSet(radar.radius) + if set then + set.raySSBO:BindBufferRange(RAY_SSBO_BINDING) + coverageShader:SetUniform("losParams", radar.bx, radar.bz, radar.radius, radar.losHeight) + coverageShader:SetUniform( + "passRect", + (radar.bx - radar.radius) * sx - 1, + (radar.bz - radar.radius) * sz - 1, + (radar.bx + radar.radius + 1) * sx - 1, + (radar.bz + radar.radius + 1) * sz - 1 + ) + passVAO:DrawArrays(GL.TRIANGLES) + end + end + coverageShader:SetUniform("coverageAbsolute", 0) + coverageShader:SetUniform("passRect", -1, -1, 1, 1) + coverageShader:Deactivate() + gl.BlendEquation(GL.FUNC_ADD) + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) +end + function widget:DrawWorld() local cmdID if selectedRadarUnitID then @@ -678,8 +795,14 @@ function widget:DrawWorld() else cmdID = select(2, spGetActiveCommand()) if cmdID == nil or cmdID >= 0 then - return - end -- not a build command + -- before game start builds are queued through the pregame build widget, not via an active command + local pregameBuild = WG["pregame-build"] + local pregameDefID = pregameBuild and pregameBuild.getPreGameDefID and pregameBuild.getPreGameDefID() + if not pregameDefID then + return + end + cmdID = -pregameDefID + end end local def = radarDefs[cmdID] @@ -779,6 +902,20 @@ function widget:DrawWorld() smoothShader:Deactivate() gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + -- 2b. under global LOS or spectator full view the engine's radar map covers everything, so union the + -- coverage of the allied radar units ourselves instead (exact, refreshed once a second) + local manualAllied = false + if SHOW_ALLIED_COVERAGE then + local _, fullView = spGetSpectatingState() + manualAllied = fullView or spGetGlobalLos() or false + if manualAllied and (now - alliedUpdatedAt) > COVERAGE_REFRESH_SECONDS then + collectAlliedRadars() + gl.Texture(0, mipTex) + gl.RenderToTexture(alliedTex, drawAlliedUnion) + alliedUpdatedAt = now + end + end + -- 3. the cubes local camX, camY, camZ = spGetCameraPosition() local dx, dy, dz = camX - cx, camY - midY, camZ - cz @@ -787,6 +924,11 @@ function widget:DrawWorld() if cellsX > 0 and cellsZ > 0 then gl.Texture(0, "$heightmap") gl.Texture(1, nextTex) + if SHOW_ALLIED_COVERAGE then + -- the engine's radar map of our ally team (one texel per radar cell), or our own union of the + -- allied radars when the engine map is all-covering (global LOS / spectator full view) + gl.Texture(4, manualAllied and alliedTex or "$info:radar") + end if hasMapDepth then gl.Texture(2, "$map_gbuffer_zvaltex") if hasModelDepth then @@ -815,6 +957,7 @@ function widget:DrawWorld() gl.DepthTest(false) gl.Texture(2, false) gl.Texture(3, false) + gl.Texture(4, false) end gl.Texture(0, false) From b2970aeff272cdbc702605b619b9715041fd4abf Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Wed, 26 Aug 2026 13:54:24 -0500 Subject: [PATCH 13/17] Move chat autocomplete from Tab to the right arrow (#8926) Moves accepting a chat autocomplete suggestion off Tab and onto the right arrow. --- .../gui_terraform_brush.lua | 4 +-- luaui/Widgets/gui_chat.lua | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua index 79abeb0be73..b263702a7d7 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua @@ -350,7 +350,7 @@ widgetState = { -- forward-declared above playSound so mute check works -- Auto-scroll transport state (per-slider, keyed by slider element id) transports = {}, -- Currently focused RmlUI input element (text/number boxes); cleared on blur. - -- Used to auto-blur when game chat is opened, so Tab autocomplete isn't stolen by RmlUI. + -- Used to auto-blur when game chat is opened, so chat keys aren't stolen by RmlUI. focusedRmlInput = nil, -- Module-shared mutable state noiseManuallyHidden = false, @@ -15086,7 +15086,7 @@ function widget:Update() end -- When game chat input is open, auto-blur any focused RmlUI text input so - -- Tab reaches the chat widget for autocomplete instead of navigating RmlUI fields. + -- keystrokes reach the chat widget instead of navigating RmlUI fields. if widgetState.focusedRmlInput and WG.chat and WG.chat.isInputActive() then widgetState.focusedRmlInput:Blur() widgetState.focusedRmlInput = nil diff --git a/luaui/Widgets/gui_chat.lua b/luaui/Widgets/gui_chat.lua index 12b2c041475..b6666a3fc18 100644 --- a/luaui/Widgets/gui_chat.lua +++ b/luaui/Widgets/gui_chat.lua @@ -3108,7 +3108,9 @@ function widget:DrawScreen() ), translatedY + (lineHeight * checkedLines) + lineHeight, } - if not activeCmdID and math_isInRect(x, y, lineArea[1], lineArea[2], lineArea[3], lineArea[4]) then + if + not activeCmdID and math_isInRect(x, y, lineArea[1], lineArea[2], lineArea[3], lineArea[4]) + then UiSelectHighlight( lineArea[1] - translatedX, lineArea[2] - translatedY - (lineHeight * checkedLines), @@ -3550,6 +3552,27 @@ function state.insertInputTextAtCursor(text) end end +function state.acceptAutocomplete() + if inputMode == "label" or not autocompleteText or not autocompleteWords[1] then + return false + end + if inputSelectionStart and inputSelectionStart ~= inputTextPosition then + return false + end + if inputTextPosition ~= utf8.len(inputText) then + return false + end + + inputText = inputText .. autocompleteText + inputTextPosition = utf8.len(inputText) + inputHistory[#inputHistory] = inputText + inputSelectionStart = nil + autocompleteText = nil + autocompleteWords = {} + + return true +end + function widget:TextInput(char) -- if it isn't working: chobby probably hijacked it if handleTextInput and not chobbyInterface and not Spring.IsGUIHidden() and showTextInput then if @@ -3870,6 +3893,8 @@ function widget:KeyPress(key, mods, isRepeat, label, unicode, scanCode, actions) inputTextPosition = 0 end cursorBlinkTimer = 0 + elseif key == 275 and not shift and state.acceptAutocomplete() then -- RIGHT, accept autocomplete + cursorBlinkTimer = 0 elseif key == 275 then -- RIGHT if shift then -- Start or extend selection From fc4519caa0d534de1a174e3b8381bd4d5c6cc371 Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 22:41:40 +0200 Subject: [PATCH 14/17] Sensor radar ranges preview lowered cubes per cell to prevent moire effect (#8930) --- .../sensor_ranges_radar_preview.vert.glsl | 32 +++++---- .../gui_sensor_ranges_radar_preview.lua | 67 ++++++++++++++----- 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl index 4d03eb4aa27..0e7aa8f0098 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl @@ -16,16 +16,14 @@ layout (location = 0) in vec4 cubeVertex; // unit cube corner: x,z in [-0.5, 0.5 uniform vec4 radarcenter_range; // cube grid center x, emitter height, cube grid center z, effective range (elmo) uniform vec4 gridParams; // radar cell size (elmo), coverage texels per side, cube spacing (elmo), cubes per radar cell edge -uniform vec4 lookupParams; // emitter cell x, emitter cell y, radius in cells, unused +uniform vec4 lookupParams; // emitter cell x, emitter cell y, radius in cells, allied coverage on (1) / off (0) uniform vec4 shapeParams; // cube width, cube height (0 = flat tile), sink below ground, lift of the top above ground uniform vec4 animParams; // time (s), seconds since the preview appeared, lod blend (0 = fine grid, 1 = double spacing), conform (1 = top follows terrain) uniform vec4 windowParams; // first cube index x, first cube index z, cubes per row, index stride (1 or 2) uniform sampler2D heightmapTex; uniform sampler2D coverageTex; -#if ALLIED_COVERAGE -uniform sampler2D radarInfoTex; // $info:radar, R = 1 where any allied radar covers the radar cell -#endif +uniform sampler2D radarInfoTex; // allied radar coverage map, R = 1 where any allied radar covers the radar cell (only read when lookupParams.w = 1) out DataVS { vec3 localPos; // position on the unit cube, for per-face shading and edge lines @@ -101,21 +99,20 @@ void main() { } float coverage = coverageState.r; -#if ALLIED_COVERAGE - // union of all allied radars from the engine's radar map; cubes covered only by those stay static - float allied = 0.0; - if (all(greaterThanEqual(worldCell, ivec2(0))) && all(lessThan(worldCell, textureSize(radarInfoTex, 0)))) { - allied = step(0.5, texelFetch(radarInfoTex, worldCell, 0).r); - } - float weight = smoothstep(0.0, 0.5, coverage); // how much of the previewed radar's animation applies here - coverage = max(coverage, allied); -#else - if (!inPreviewDisc) { + // with allied coverage enabled (lookupParams.w), cubes covered only by other allied radars are drawn too but + // stay static; the previewed radar's animation applies in proportion to its own coverage + float weight = 1.0; + if (lookupParams.w > 0.5) { + float allied = 0.0; + if (all(greaterThanEqual(worldCell, ivec2(0))) && all(lessThan(worldCell, textureSize(radarInfoTex, 0)))) { + allied = step(0.5, texelFetch(radarInfoTex, worldCell, 0).r); + } + weight = smoothstep(0.0, 0.5, coverage); + coverage = max(coverage, allied); + } else if (!inPreviewDisc) { cullInstance(); return; } - float weight = 1.0; -#endif float distN = dist / range; float time = animParams.x; @@ -160,8 +157,9 @@ void main() { // shear them (the uphill side sinks into the slope, the downhill side hovers a little; the bottom face // covers that). Flat tiles (conform = 1) get a planar tilt from the terrain gradient around their // center, capped at TILE_MAX_TILT degrees and fading back to flat on cliffs, where tilted tiles look odd. - vec2 vertexXZ = cellXZ + cubeVertex.xz * width; float centerGround = heightAtWorldPos(cellXZ); + + vec2 vertexXZ = cellXZ + cubeVertex.xz * width; float tilt = 0.0; if (animParams.w > 0.0) { float halfW = 0.5 * width; diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index 7624bed988b..742f3e98679 100644 --- a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua +++ b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua @@ -12,6 +12,8 @@ function widget:GetInfo() } end +-- springsettings RadarPreviewAlliedCoverage (0/1, default off): also draw the coverage of all allied radars (from the engine's radar map) + ------------------------------------------------------------------------------------------------ -- How it works -- 0. Mip pass (once a second while shown): rebuilds the engine's radar-mip-level heightmap @@ -31,8 +33,8 @@ end -- Tunables -- Cubes are laid out as an NxN block centered inside every radar cell (cell size = 8 << radarMipLevel elmo); -- N by radarMipLevel, so the look stays consistent when the game's radar resolution changes. -local CUBES_PER_CELL = { [1] = 2, [2] = 3, [3] = 5, [4] = 8 } -local CUBE_FILL = 0.28 -- cube width as a fraction of the cube spacing (spacing = radar cell size / N) +local CUBES_PER_CELL = { [1] = 1, [2] = 1, [3] = 2, [4] = 3 } +local CUBE_FILL = 0.22 -- cube width as a fraction of the cube spacing (spacing = radar cell size / N) local CUBE_SINK = 2 -- elmos the cube base is pushed below ground, so cubes never float on slopes local CUBE_SHAPE = "tile" -- default shape, see CUBE_SHAPES; switch at runtime with WG.radarPreview.setShape(name) local CUBE_SHAPES = { @@ -45,9 +47,10 @@ local CUBE_SHAPES = { } local LIFT_PER_DISTANCE = 0.001 -- extra lift per elmo of camera distance, keeps flat shapes above the terrain LOD mesh local COVERAGE_SMOOTH = false -- true: blend coverage between radar cells (prettier), false: exact engine cells (blocky) -local SHOW_ALLIED_COVERAGE = true -- also draw the coverage of all allied radars (from the engine's radar map) while the preview is shown; only the previewed radar animates +local ALLIED_COVERAGE_POLL_SECONDS = 2 local COVERAGE_REFRESH_SECONDS = 1.0 -- periodic heightmap/coverage rebuild so terraforming shows up local SMOOTH_RATE = 14 -- 1/s, how fast the cubes follow coverage changes (higher = snappier) +local SMOOTH_RATE_DRAG = 60 -- 1/s, used while the placement preview is dragged across radar cells, so cubes keep up with the cursor local LOD_MAX_INSTANCES = 0 -- > 0: thin the grid to double spacing once more cubes than this are on screen (cubes visibly fill in/out with zoom; try 50000 on integrated graphics) local FOOTPRINT_MARGIN = 48 -- elmos of slack around the screen's ground footprint local MAX_RADIUS_CELLS = 256 -- sanity limit of the radar radius in cells (coverage texture and ray table size) @@ -61,7 +64,6 @@ local hasModelDepth = hasMapDepth and Spring.GetConfigString("AllowDeferredModel local shaderConfig = { TERRAIN_DEPTH_TEST = hasMapDepth and 1 or 0, MODEL_DEPTH_TEST = hasModelDepth and 1 or 0, - ALLIED_COVERAGE = SHOW_ALLIED_COVERAGE and 1 or 0, ALLIED_COLOR = "vec3(0.35, 0.62, 0.50)", -- cubes covered only by other allied radars ALLIED_ALPHA = 0.7, -- their opacity relative to the previewed radar's cubes MIN_COVERAGE = 0.04, -- cubes below this (smoothed) coverage are not drawn @@ -185,6 +187,8 @@ local alliedTex = nil -- map-wide union of the allied radars' coverage, only use local alliedUpdatedAt = -mathHuge local alliedRadars = {} -- reused scratch list of { bx, bz, radius, losHeight } local alliedRadarCount = 0 +local showAllied = false -- live value of the RadarPreviewAlliedCoverage configint setting +local alliedConfigCheckedAt = -mathHuge local sets = {} -- radius in cells -> coverage/state textures and grid dimensions local mousepos = { 0, 0, 0 } local selectedRadarUnitID = false @@ -195,6 +199,7 @@ local lastDrawFrame = -10 local lastDrawTime = 0 local lastRadius = nil local spawnStart = 0 +local cellMovedAt = -mathHuge -- last time the previewed emitter moved to another radar cell local passVsPath = "LuaUI/Shaders/sensor_ranges_radar_preview_pass.vert.glsl" @@ -249,7 +254,7 @@ local cubeShaderCache = { coverageTex = 1, mapDepths = hasMapDepth and 2 or nil, modelDepths = hasModelDepth and 3 or nil, - radarInfoTex = SHOW_ALLIED_COVERAGE and 4 or nil, + radarInfoTex = 4, }, uniformFloat = { radarcenter_range = { 0, 0, 0, 2000 }, @@ -451,7 +456,15 @@ local function buildRayData(radius) end end end - return data, listOffset -- vec4 entries + -- shader storage buffers are allocated in 64 byte (4 x vec4) units and the upload must fill them + local entries = listOffset + while entries % 4 ~= 0 do + for _ = 1, 4 do + data[#data + 1] = 0 + end + entries = entries + 1 + end + return data, entries -- vec4 entries end local function makeSet(radiusCells) @@ -480,7 +493,9 @@ local function makeSet(radiusCells) if not set.raySSBO then return nil end - set.raySSBO:Define(rayEntries, { { id = 0, name = "rayData", size = 4 } }) + -- LuaVBO shader storage buffers use std140 vec4 attributes: with size = 4 one element is 4 x vec4 = 64 bytes + -- and the upload consumes 16 floats per element, so the entry count is padded to a multiple of 4 above + set.raySSBO:Define(rayEntries / 4, { { id = 0, name = "rayData", size = 4 } }) set.raySSBO:Upload(rayData) return set end @@ -574,12 +589,10 @@ local function initgl4() goodbye("Failed to create the radar preview heightmap texture") return false end - if SHOW_ALLIED_COVERAGE then - alliedTex = makeDataTexture(MAP_CELLS_X, MAP_CELLS_Z, GL_R16F, GL.NEAREST) - if not alliedTex then - goodbye("Failed to create the allied radar coverage texture") - return false - end + alliedTex = makeDataTexture(MAP_CELLS_X, MAP_CELLS_Z, GL_R16F, GL.NEAREST) + if not alliedTex then + goodbye("Failed to create the allied radar coverage texture") + return false end for _, def in pairs(radarDefs) do @@ -606,6 +619,18 @@ local function setShape(name) return true end +local function readAlliedConfig() + showAllied = Spring.GetConfigInt("RadarPreviewAlliedCoverage", 0) ~= 0 +end + +function widget:Update() + local now = osClock() + if now - alliedConfigCheckedAt > ALLIED_COVERAGE_POLL_SECONDS then + alliedConfigCheckedAt = now + readAlliedConfig() + end +end + function widget:Initialize() if not gl.CreateShader then -- no shader support, so just remove the widget itself, especially for headless widgetHandler:RemoveWidget() @@ -614,6 +639,7 @@ function widget:Initialize() if not initgl4() then return end + readAlliedConfig() WG.radarPreview = { setShape = setShape, getShape = function() @@ -685,7 +711,7 @@ local function getCubeWindow(set, bx, bz, camX, camY, camZ) local radius = set.radius local x0, x1 = (bx - radius) * n, (bx + radius + 1) * n - 1 local z0, z1 = (bz - radius) * n, (bz + radius + 1) * n - 1 - if SHOW_ALLIED_COVERAGE then -- allied coverage can be anywhere on the map; uncovered cubes exit the vertex shader early + if showAllied then -- allied coverage can be anywhere on the map; uncovered cubes exit the vertex shader early x0, x1, z0, z1 = 0, MAP_CUBES_X - 1, 0, MAP_CUBES_Z - 1 end local minX, maxX, minZ, maxZ = getScreenFootprint(camX, camY, camZ) @@ -879,6 +905,9 @@ function widget:DrawWorld() if set.bx then shiftX = bx - set.bx shiftZ = bz - set.bz + if shiftX ~= 0 or shiftZ ~= 0 then + cellMovedAt = now + end end if fresh or refresh or set.bx ~= bx or set.bz ~= bz or set.losHeight ~= losHeight then gl.Texture(0, mipTex) @@ -897,7 +926,9 @@ function widget:DrawWorld() gl.Texture(0, prevTex) gl.Texture(1, set.target) smoothShader:Activate() - smoothShader:SetUniform("smoothParams", shiftX, shiftZ, 1 - mathExp(-dt * SMOOTH_RATE), fresh and 1 or 0) + -- while the placement preview is being dragged across radar cells the cubes must keep up with the cursor + local smoothRate = (not selectedRadarUnitID and (now - cellMovedAt) < 0.3) and SMOOTH_RATE_DRAG or SMOOTH_RATE + smoothShader:SetUniform("smoothParams", shiftX, shiftZ, 1 - mathExp(-dt * smoothRate), fresh and 1 or 0) gl.RenderToTexture(nextTex, drawPass) smoothShader:Deactivate() gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) @@ -905,7 +936,7 @@ function widget:DrawWorld() -- 2b. under global LOS or spectator full view the engine's radar map covers everything, so union the -- coverage of the allied radar units ourselves instead (exact, refreshed once a second) local manualAllied = false - if SHOW_ALLIED_COVERAGE then + if showAllied then local _, fullView = spGetSpectatingState() manualAllied = fullView or spGetGlobalLos() or false if manualAllied and (now - alliedUpdatedAt) > COVERAGE_REFRESH_SECONDS then @@ -924,7 +955,7 @@ function widget:DrawWorld() if cellsX > 0 and cellsZ > 0 then gl.Texture(0, "$heightmap") gl.Texture(1, nextTex) - if SHOW_ALLIED_COVERAGE then + if showAllied then -- the engine's radar map of our ally team (one texel per radar cell), or our own union of the -- allied radars when the engine map is all-covering (global LOS / spectator full view) gl.Texture(4, manualAllied and alliedTex or "$info:radar") @@ -943,7 +974,7 @@ function widget:DrawWorld() cubeShader:Activate() cubeShader:SetUniform("radarcenter_range", cx, losHeight, cz, range) cubeShader:SetUniform("gridParams", RADAR_CELL, set.N, CUBE_SPACING, CUBES_PER_CELL_EDGE) - cubeShader:SetUniform("lookupParams", bx, bz, radius, 0) + cubeShader:SetUniform("lookupParams", bx, bz, radius, showAllied and 1 or 0) cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height * CUBE_WIDTH, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE) cubeShader:SetUniform("animParams", now, now - spawnStart, lodBlend, shape.conform) cubeShader:SetUniform("windowParams", x0, z0, cellsX, stride) From 4f1b65d996dd673a51d10cba76929bc36ed00078 Mon Sep 17 00:00:00 2001 From: Mitvit <112669238+Mitvit@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:42:21 +0300 Subject: [PATCH 15/17] legmex description (#8928) Update leg mex description to match its new stats. --- language/en/units.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/language/en/units.json b/language/en/units.json index caa3e3f9fd3..1d067c1fe37 100644 --- a/language/en/units.json +++ b/language/en/units.json @@ -1723,7 +1723,7 @@ "leglts": "Light Air Transport", "leglupara": "Bomb-Resistant Medium-Range Anti-Air Flak Battery", "legmed": "Heavy Long-Range Salvo Rocket Tank", - "legmex": "Extracts Slightly Reduced Metal and Produces 7 Energy", + "legmex": "Extracts Metal from Metalspots", "legmext15": "Extracts Extra Metal at a High Energy Cost", "legmg": "Heavy Land/Air Gatling Gun Turret", "legmh": "Hovercraft Rocket Launcher", From e77b0ed98b4e3061984ecbe2fedf282c6e038362 Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 23:15:12 +0200 Subject: [PATCH 16/17] sensor radar ranges preview: added background color and outline + default enabled ally radar ranges (#8931) --- .../sensor_ranges_radar_preview.frag.glsl | 23 +++++++ .../sensor_ranges_radar_preview.vert.glsl | 66 ++++++++++++++++++- .../gui_sensor_ranges_radar_preview.lua | 41 ++++++++---- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl b/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl index 0cb8b6a0c4b..799e96eaf6a 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl @@ -13,8 +13,12 @@ in DataVS { vec3 localPos; // position on the unit cube vec4 fx; // coverage, glow, beam, spawn float previewWeight; // 1 = covered by the previewed radar, 0 = only by other allied radars + flat vec4 outlineSides; // background pass: 1 where this cell's -x, +x, -z, +z side is on the previewed radar's own coverage border + flat vec4 unionOutlineSides; // background pass: 1 where that side borders a radar cell not covered by anyone }; +uniform vec4 modeParams; // x = 1: background pass + // Occlusion is tested against the deferred g-buffer depths instead of the regular depth buffer, so // terrain (and units) hide the cubes but things drawn into the depth buffer by widgets, like grass, don't. #if TERRAIN_DEPTH_TEST @@ -34,6 +38,11 @@ const vec3 baseColor = BASE_COLOR; const vec3 highlightColor = HIGHLIGHT_COLOR; const vec3 alliedColor = ALLIED_COLOR; const float alliedAlpha = float(ALLIED_ALPHA); +const vec3 backgroundColor = BACKGROUND_COLOR; +const float backgroundAlpha = float(BACKGROUND_ALPHA); +const vec3 outlineColor = OUTLINE_COLOR; +const float outlineAlpha = float(OUTLINE_ALPHA); +const float outlineWidth = float(OUTLINE_WIDTH); // pixels at 1080p, scaled with the vertical resolution const float baseAlpha = float(BASE_ALPHA); const float lineAlpha = float(LINE_ALPHA); const float depthBias = 1e-6; // window-space depth tolerance (a few elmo far away, sub-elmo up close) @@ -50,6 +59,20 @@ void main() { } #endif + if (modeParams.x > 0.5) { + // background sheet: flat fill, plus an outline on the sides that border uncovered radar cells + vec2 edgeDist = 0.5 - abs(localPos.xz); + float outlinePixels = outlineWidth * viewGeometry.y / 1080.0; + vec2 px = max(fwidth(localPos.xz), vec2(1e-5)) * outlinePixels; + vec2 nearSide = step(vec2(0.0), localPos.xz); + vec2 side = max(mix(outlineSides.xz, outlineSides.yw, nearSide), mix(unionOutlineSides.xz, unionOutlineSides.yw, nearSide)); + vec2 lineAmount = side * (1.0 - smoothstep(vec2(0.0), px, edgeDist)); + float outline = max(lineAmount.x, lineAmount.y); + float fade = fx.w * smoothstep(0.0, 0.5, fx.x) * mix(alliedAlpha, 1.0, previewWeight); + fragColor = vec4(mix(backgroundColor, outlineColor, outline), mix(backgroundAlpha, outlineAlpha, outline) * fade); + return; + } + // which face are we on? the largest |coordinate| of the centered cube decides vec3 centered = vec3(localPos.x, localPos.y - 0.5, localPos.z); vec3 a = abs(centered); diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl index 0e7aa8f0098..99d95de87b8 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl @@ -20,6 +20,7 @@ uniform vec4 lookupParams; // emitter cell x, emitter cell y, radius in cel uniform vec4 shapeParams; // cube width, cube height (0 = flat tile), sink below ground, lift of the top above ground uniform vec4 animParams; // time (s), seconds since the preview appeared, lod blend (0 = fine grid, 1 = double spacing), conform (1 = top follows terrain) uniform vec4 windowParams; // first cube index x, first cube index z, cubes per row, index stride (1 or 2) +uniform vec4 modeParams; // x = 1: background pass (seamless flat sheet per cell + outline at uncovered borders) uniform sampler2D heightmapTex; uniform sampler2D coverageTex; @@ -29,6 +30,8 @@ out DataVS { vec3 localPos; // position on the unit cube, for per-face shading and edge lines vec4 fx; // coverage, glow, beam, spawn float previewWeight; // 1 = covered by the previewed radar, 0 = only by other allied radars + flat vec4 outlineSides; // background pass: 1 where this cell's -x, +x, -z, +z side is on the previewed radar's own coverage border + flat vec4 unionOutlineSides; // background pass: 1 where that side borders a radar cell not covered by anyone }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -58,12 +61,28 @@ float heightAtWorldPos(vec2 w) { return max(0.0, textureLod(heightmapTex, uvhm, 0.0).x); } +// is the radar cell covered by the previewed radar (and, with includeAllied and allied coverage enabled, any allied radar)? +float coveredAt(ivec2 radarCell, bool includeAllied) { + int radius = int(lookupParams.z); + ivec2 texel = radarCell - ivec2(lookupParams.xy) + ivec2(radius); + float c = 0.0; + if (all(greaterThanEqual(texel, ivec2(0))) && all(lessThan(texel, ivec2(int(gridParams.y))))) { + c = texelFetch(coverageTex, texel, 0).r; + } + if (includeAllied && lookupParams.w > 0.5 && all(greaterThanEqual(radarCell, ivec2(0))) && all(lessThan(radarCell, textureSize(radarInfoTex, 0)))) { + c = max(c, texelFetch(radarInfoTex, radarCell, 0).r); + } + return step(0.5, c); +} + // Every corner lands on the same clip-space point: zero area, nothing gets rasterized. void cullInstance() { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); localPos = vec3(0.0); fx = vec4(0.0); previewWeight = 0.0; + outlineSides = vec4(0.0); + unionOutlineSides = vec4(0.0); } void main() { @@ -77,6 +96,10 @@ void main() { float lodBlend = animParams.z; float isFine = float((cell.x | cell.y) & 1); float lodScale = 1.0 - isFine * lodBlend; + bool background = modeParams.x > 0.5; + if (background) { + lodScale = 1.0; // the sheet just uses wider tiles at the coarse stride + } float range = radarcenter_range.w; vec2 cellXZ = (vec2(cell) + 0.5) * gridParams.z; @@ -128,6 +151,38 @@ void main() { return; } + // background pass: which sides of this cell border an uncovered radar cell (only cells on their radar + // cell's border can, so the neighbour lookups are rare) + // outlineSides: the previewed radar's own coverage border (always drawn, even inside allied coverage); + // unionOutlineSides: the border of all coverage with uncovered cells + outlineSides = vec4(0.0); + unionOutlineSides = vec4(0.0); + if (background) { + int n = int(gridParams.w); + ivec2 sub = cell - worldCell * n; + float ownCovered = step(0.5, coverageState.r); + if (sub.x == 0) { + ivec2 rc = worldCell + ivec2(-1, 0); + outlineSides.x = ownCovered * (1.0 - coveredAt(rc, false)); + unionOutlineSides.x = 1.0 - coveredAt(rc, true); + } + if (sub.x >= n - stride) { + ivec2 rc = worldCell + ivec2(1, 0); + outlineSides.y = ownCovered * (1.0 - coveredAt(rc, false)); + unionOutlineSides.y = 1.0 - coveredAt(rc, true); + } + if (sub.y == 0) { + ivec2 rc = worldCell + ivec2(0, -1); + outlineSides.z = ownCovered * (1.0 - coveredAt(rc, false)); + unionOutlineSides.z = 1.0 - coveredAt(rc, true); + } + if (sub.y >= n - stride) { + ivec2 rc = worldCell + ivec2(0, 1); + outlineSides.w = ownCovered * (1.0 - coveredAt(rc, false)); + unionOutlineSides.w = 1.0 - coveredAt(rc, true); + } + } + // rotating radar sweep: a bright leading edge SWEEP_BEAM degrees wide, with a trail fading out over // SWEEP_TRAIL degrees behind it float angle = atan(fromCenter.y, fromCenter.x) / (2.0 * PI) + 0.5; @@ -152,6 +207,10 @@ void main() { float coarseGrow = lodBlend * (1.0 - isFine); height *= lodScale * (1.0 + 0.5 * coarseGrow); width *= lodScale * (1.0 + 0.9 * coarseGrow); + if (background) { + width = gridParams.z * float(stride); // exactly one cell (or LOD block): the tiles form a seamless sheet + height = 0.0; + } // Cubes are rigid: every corner uses the cell center height, so slopes and cliffs never stretch or // shear them (the uphill side sinks into the slope, the downhill side hovers a little; the bottom face @@ -161,7 +220,7 @@ void main() { vec2 vertexXZ = cellXZ + cubeVertex.xz * width; float tilt = 0.0; - if (animParams.w > 0.0) { + if (animParams.w > 0.0 && !background) { float halfW = 0.5 * width; vec2 grad = vec2( heightAtWorldPos(cellXZ + vec2(halfW, 0.0)) - heightAtWorldPos(cellXZ - vec2(halfW, 0.0)), @@ -172,6 +231,11 @@ void main() { } float base = centerGround + tilt - shapeParams.z; float top = centerGround + tilt + shapeParams.w + height; + if (background) { + // per-corner terrain height: neighbouring tiles share their corners, so the sheet has no seams + top = heightAtWorldPos(vertexXZ) + shapeParams.w; + base = top; + } vec3 worldPos = vec3(vertexXZ.x, mix(base, top, cubeVertex.y), vertexXZ.y); localPos = cubeVertex.xyz; diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index 742f3e98679..0654e433012 100644 --- a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua +++ b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua @@ -12,7 +12,7 @@ function widget:GetInfo() } end --- springsettings RadarPreviewAlliedCoverage (0/1, default off): also draw the coverage of all allied radars (from the engine's radar map) +-- springsettings RadarPreviewAlliedCoverage (0/1, default on): also draw the coverage of all allied radars (from the engine's radar map) ------------------------------------------------------------------------------------------------ -- How it works @@ -40,14 +40,14 @@ local CUBE_SHAPE = "tile" -- default shape, see CUBE_SHAPES; switch at runtime w local CUBE_SHAPES = { -- height at full coverage as a multiple of the cube width, lift of the top face above ground (elmo), -- conform = top face tilts with the terrain - cube = { height = 2.0, lift = 0, conform = 0 }, - slab = { height = 1.0, lift = 0, conform = 0 }, - tile = { height = 0.5, lift = 0, conform = 0 }, + cube = { height = 0.8, lift = 0, conform = 0 }, + tile = { height = 0.2, lift = 0, conform = 0 }, flat = { height = 0, lift = 1.5, conform = 1 }, -- flat square } local LIFT_PER_DISTANCE = 0.001 -- extra lift per elmo of camera distance, keeps flat shapes above the terrain LOD mesh local COVERAGE_SMOOTH = false -- true: blend coverage between radar cells (prettier), false: exact engine cells (blocky) -local ALLIED_COVERAGE_POLL_SECONDS = 2 +local ALLIED_COVERAGE_POLL_SECONDS = 2 -- how often the RadarPreviewAlliedCoverage / RadarPreviewBackground settings are re-read +local BACKGROUND_LIFT = 2.0 -- elmos the background sheet (RadarPreviewBackground configint, default on) floats above the terrain local COVERAGE_REFRESH_SECONDS = 1.0 -- periodic heightmap/coverage rebuild so terraforming shows up local SMOOTH_RATE = 14 -- 1/s, how fast the cubes follow coverage changes (higher = snappier) local SMOOTH_RATE_DRAG = 60 -- 1/s, used while the placement preview is dragged across radar cells, so cubes keep up with the cursor @@ -65,7 +65,12 @@ local shaderConfig = { TERRAIN_DEPTH_TEST = hasMapDepth and 1 or 0, MODEL_DEPTH_TEST = hasModelDepth and 1 or 0, ALLIED_COLOR = "vec3(0.35, 0.62, 0.50)", -- cubes covered only by other allied radars - ALLIED_ALPHA = 0.7, -- their opacity relative to the previewed radar's cubes + ALLIED_ALPHA = 0.45, -- their opacity relative to the previewed radar's cubes + BACKGROUND_COLOR = "vec3(0.10, 0.45, 0.28)", -- background sheet under the cubes (RadarPreviewBackground setting) + BACKGROUND_ALPHA = 0.14, + OUTLINE_COLOR = "vec3(0.45, 1.00, 0.57)", -- outline along the border with uncovered radar cells + OUTLINE_ALPHA = 0.24, + OUTLINE_WIDTH = 2, -- outline width in pixels at 1080p, scaled with the screen's vertical resolution (0.8 = 2 px on a 2721 px tall screen) MIN_COVERAGE = 0.04, -- cubes below this (smoothed) coverage are not drawn SWEEP_SPEED = 0.11, -- radar sweep revolutions per second SWEEP_TRAIL = 30.0, -- degrees: the trail fades out this far behind the sweep's leading edge @@ -74,9 +79,9 @@ local shaderConfig = { SPAWN_SPEED = 2.5, -- radar ranges per second the spawn ripple travels outward SPAWN_BUMP = 0.25, -- width of the overshoot behind the ripple front, in radar ranges PULSE_SPACING = 180.0, -- elmos between the outward travelling wave rings - PULSE_SPEED = 90.0, -- elmos per second the rings travel + PULSE_SPEED = 100.0, -- elmos per second the rings travel PULSE_POWER = 4.5, -- higher = narrower rings - PULSE_STRENGTH = 1.0, -- how much the rings raise/brighten cubes + PULSE_STRENGTH = 1.5, -- how much the rings raise/brighten cubes EDGE_STRENGTH = 0.12, -- how much cubes at the coverage boundary (next to an uncovered radar cell) brighten; 0 disables RIM_STRENGTH = 0.22, -- how much the outermost ring of cubes brightens; 0 disables TILE_MAX_TILT = 20.0, -- degrees: flat tiles follow the terrain slope up to this angle @@ -188,6 +193,7 @@ local alliedUpdatedAt = -mathHuge local alliedRadars = {} -- reused scratch list of { bx, bz, radius, losHeight } local alliedRadarCount = 0 local showAllied = false -- live value of the RadarPreviewAlliedCoverage configint setting +local showBackground = true -- live value of the RadarPreviewBackground configint setting local alliedConfigCheckedAt = -mathHuge local sets = {} -- radius in cells -> coverage/state textures and grid dimensions local mousepos = { 0, 0, 0 } @@ -263,6 +269,7 @@ local cubeShaderCache = { shapeParams = { CUBE_WIDTH, 6, CUBE_SINK, 0 }, animParams = { 0, 0, 0, 0 }, windowParams = { 0, 0, 1, 1 }, + modeParams = { 0, 0, 0, 0 }, }, shaderConfig = shaderConfig, } @@ -619,15 +626,16 @@ local function setShape(name) return true end -local function readAlliedConfig() - showAllied = Spring.GetConfigInt("RadarPreviewAlliedCoverage", 0) ~= 0 +local function readConfig() + showAllied = Spring.GetConfigInt("RadarPreviewAlliedCoverage", 1) ~= 0 + showBackground = Spring.GetConfigInt("RadarPreviewBackground", 1) ~= 0 end function widget:Update() local now = osClock() if now - alliedConfigCheckedAt > ALLIED_COVERAGE_POLL_SECONDS then alliedConfigCheckedAt = now - readAlliedConfig() + readConfig() end end @@ -639,7 +647,7 @@ function widget:Initialize() if not initgl4() then return end - readAlliedConfig() + readConfig() WG.radarPreview = { setShape = setShape, getShape = function() @@ -975,9 +983,16 @@ function widget:DrawWorld() cubeShader:SetUniform("radarcenter_range", cx, losHeight, cz, range) cubeShader:SetUniform("gridParams", RADAR_CELL, set.N, CUBE_SPACING, CUBES_PER_CELL_EDGE) cubeShader:SetUniform("lookupParams", bx, bz, radius, showAllied and 1 or 0) - cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height * CUBE_WIDTH, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE) cubeShader:SetUniform("animParams", now, now - spawnStart, lodBlend, shape.conform) cubeShader:SetUniform("windowParams", x0, z0, cellsX, stride) + if showBackground then + -- background sheet under the cubes, outlined along the border with uncovered cells + cubeShader:SetUniform("modeParams", 1, 0, 0, 0) + cubeShader:SetUniform("shapeParams", CUBE_WIDTH, 0, CUBE_SINK, BACKGROUND_LIFT + camDist * LIFT_PER_DISTANCE) + tileVAO:DrawElements(GL.TRIANGLES, TILE_INDEX_COUNT, 0, cellsX * cellsZ, 0) + cubeShader:SetUniform("modeParams", 0, 0, 0, 0) + end + cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height * CUBE_WIDTH, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE) if shape.height > 0 then cubeVAO:DrawElements(GL.TRIANGLES, CUBE_INDEX_COUNT, 0, cellsX * cellsZ, 0) else From 39d04f876d7a96af37e4f13ed8b1b4de514f0182 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:25:01 +0000 Subject: [PATCH 17/17] Update Lua libary submodule --- recoil-lua-library | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recoil-lua-library b/recoil-lua-library index 7f31efdc06d..b105bdf44b0 160000 --- a/recoil-lua-library +++ b/recoil-lua-library @@ -1 +1 @@ -Subproject commit 7f31efdc06d90324f641700f74389cb7dcf174ed +Subproject commit b105bdf44b0382ec4894db0de0cd909456350cb1