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", 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/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) 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_continuous_aim.lua b/luarules/gadgets/unit_continuous_aim.lua index e1174e33fd7..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, @@ -106,7 +107,7 @@ local convertedUnitsNames = { legcen = 3, legfloat = 5, leggat = 5, - leggob = 5, + leggob = 2, leggobt3 = 5, leginc = 1, cordemon = 6, 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 64f0599066a..7eb7daf5cce 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(target, allyTeam, true) + if not los then + return true, true end - local los = spGetUnitLosState(targetData.target, allyTeam, true) - return not los or los % 4 == 0 + return los % 4 == 0, false end -------------------------------------------------------------------------------- @@ -518,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 @@ -690,6 +717,7 @@ if gadgetHandler:IsSyncedCode() then ignoreStop = ignoreStop, userTarget = userTarget, target = target, + unseen = unseenGracePasses, sent = false, } end @@ -716,6 +744,7 @@ if gadgetHandler:IsSyncedCode() then ignoreStop = ignoreStop, userTarget = userTarget, target = target, + unseen = unseenGracePasses, sent = false, }, } @@ -730,6 +759,7 @@ if gadgetHandler:IsSyncedCode() then ignoreStop = ignoreStop, userTarget = userTarget, target = target, + unseen = unseenGracePasses, sent = false, }, } @@ -833,7 +863,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 @@ -1031,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/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 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/Shaders/sensor_ranges_radar_preview.frag.glsl b/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl index 095b937d2b2..799e96eaf6a 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.frag.glsl @@ -10,10 +10,15 @@ //__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 + 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 @@ -31,6 +36,13 @@ 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 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) @@ -47,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); @@ -80,11 +106,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..99d95de87b8 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl @@ -16,17 +16,22 @@ 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 vec4 modeParams; // x = 1: background pass (seamless flat sheet per cell + outline at uncovered borders) uniform sampler2D heightmapTex; uniform sampler2D coverageTex; +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 - 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 + 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__ @@ -56,11 +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() { @@ -74,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; @@ -85,43 +111,92 @@ 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; + + // 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; } - // 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 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(); 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; 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); @@ -132,15 +207,20 @@ 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 // 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) { + 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)), @@ -151,9 +231,15 @@ 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; 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/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/Widgets/gui_chat.lua b/luaui/Widgets/gui_chat.lua index 34229172ffd..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 @@ -3563,6 +3586,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 @@ -3860,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 @@ -3926,7 +3961,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 +4087,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 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 ------------------------------------------------------------------------------- diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index bc99f5b4b20..0654e433012 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 on): 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,22 +33,24 @@ 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 = { -- 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 -- 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 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) @@ -60,6 +64,13 @@ 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_COLOR = "vec3(0.35, 0.62, 0.50)", -- cubes covered only by other allied radars + 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 @@ -68,18 +79,18 @@ 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 - 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 + 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 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 +110,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 +138,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 +188,13 @@ 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 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 } local selectedRadarUnitID = false @@ -175,6 +205,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" @@ -200,6 +231,8 @@ local coverageShaderCache = { }, uniformFloat = { losParams = { 0, 0, 1, 0 }, + coverageAbsolute = { 0 }, + passRect = { -1, -1, 1, 1 }, }, shaderConfig = shaderConfig, } @@ -227,6 +260,7 @@ local cubeShaderCache = { coverageTex = 1, mapDepths = hasMapDepth and 2 or nil, modelDepths = hasModelDepth and 3 or nil, + radarInfoTex = 4, }, uniformFloat = { radarcenter_range = { 0, 0, 0, 2000 }, @@ -235,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, } @@ -428,7 +463,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) @@ -457,23 +500,27 @@ 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 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 +528,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 +596,11 @@ local function initgl4() goodbye("Failed to create the radar preview heightmap 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 if not sets[def.radiusCells] then @@ -560,6 +626,19 @@ local function setShape(name) return true end +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 + readConfig() + end +end + function widget:Initialize() if not gl.CreateShader then -- no shader support, so just remove the widget itself, especially for headless widgetHandler:RemoveWidget() @@ -568,6 +647,7 @@ function widget:Initialize() if not initgl4() then return end + readConfig() WG.radarPreview = { setShape = setShape, getShape = function() @@ -639,6 +719,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 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) if minX then local margin = mathCeil(FOOTPRINT_MARGIN / CUBE_SPACING) @@ -666,6 +749,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 +829,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] @@ -756,6 +913,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) @@ -774,11 +934,27 @@ 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) + -- 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 showAllied 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 +963,11 @@ function widget:DrawWorld() if cellsX > 0 and cellsZ > 0 then gl.Texture(0, "$heightmap") gl.Texture(1, nextTex) + 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") + end if hasMapDepth then gl.Texture(2, "$map_gbuffer_zvaltex") if hasModelDepth then @@ -801,10 +982,17 @@ 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("shapeParams", CUBE_WIDTH, shape.height * CUBE_WIDTH, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE) + cubeShader:SetUniform("lookupParams", bx, bz, radius, showAllied and 1 or 0) 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 @@ -815,6 +1003,7 @@ function widget:DrawWorld() gl.DepthTest(false) gl.Texture(2, false) gl.Texture(3, false) + gl.Texture(4, false) end gl.Texture(0, false) 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 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 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 2391faddf0e..4e4331e6710 100644 Binary files a/scripts/Units/armpw.cob and b/scripts/Units/armpw.cob differ diff --git a/scripts/Units/corak.bos b/scripts/Units/corak.bos index 855d04bfef7..ed66f7802a2 100644 --- a/scripts/Units/corak.bos +++ b/scripts/Units/corak.bos @@ -1000,13 +1000,35 @@ ExecuteRestoreAfterDelay() return (1); } - turn aimy1 to y-axis <0> 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 10f892fed0f..e2eb19186c1 100644 Binary files a/scripts/Units/corak.cob and b/scripts/Units/corak.cob differ 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 10b8ae4a06f..32ec477f258 100644 Binary files a/scripts/Units/leggob.cob and b/scripts/Units/leggob.cob differ 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 9c35dd21097..63dc0c640de 100644 Binary files a/scripts/Units/scavboss/corakt4.cob and b/scripts/Units/scavboss/corakt4.cob differ diff --git a/scripts/Units/scavboss/weapon1control.h b/scripts/Units/scavboss/weapon1control.h index c93a74dc8bd..d8f24d55d56 100644 --- a/scripts/Units/scavboss/weapon1control.h +++ b/scripts/Units/scavboss/weapon1control.h @@ -165,4 +165,8 @@ Weapon1SetWtdAim(pitch, heading) { wtdHead1 = heading; wtdPitch1 = <0> - 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/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} 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`. 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", }, 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,