From 73b7e6b4cc9698ceb5458fd8d219eaf15be1b973 Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 9 Sep 2026 12:47:17 +0200 Subject: [PATCH 1/3] cus gl4: shadow pass tex perf improvement (#9161) --- luarules/gadgets/cus_gl4.lua | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/luarules/gadgets/cus_gl4.lua b/luarules/gadgets/cus_gl4.lua index 5cfd96e0e55..a9f3683a909 100644 --- a/luarules/gadgets/cus_gl4.lua +++ b/luarules/gadgets/cus_gl4.lua @@ -899,6 +899,12 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila return (compilationResult and luaShader) or nil end +-- {shaderName : {textureUnit : true}}: the texture units the shadow pass has to bind for a +-- material. The shadow shaders never sample anything except texture2 (alpha test, unit 1), +-- and only when HASALPHASHADOWS is defined, so every other gl.Texture call in that pass +-- (tex1, normal map, shadow map, reflection, info, BRDF LUT, noise) is wasted engine time. +local shadowPassTextureUnits = {} + local function compileMaterialShader(template, name, recompilation) --Spring.Echo("Compiling", template, name) local forwardShader = CompileLuaShader( @@ -943,6 +949,14 @@ local function compileMaterialShader(template, name, recompilation) shaders[0][name] = deferredShader shaders[5][name] = reflectionShader shaders[16][name] = shadowShader + + local shadowNeedsAlphaTex = false + for _, defline in ipairs(template.shadowDefinitions or {}) do + if type(defline) == "string" and defline:find("#define%s+HASALPHASHADOWS") then + shadowNeedsAlphaTex = true + end + end + shadowPassTextureUnits[name] = shadowNeedsAlphaTex and { [1] = true } or {} return true end @@ -2366,6 +2380,8 @@ local function ExecuteDrawPass(drawPass) tracy.ZoneEnd() local shaderTable = shaders[drawPass][shaderName] + -- shadow pass: bind only the units its shader samples (see shadowPassTextureUnits) + local wantedTextureUnits = (drawPass == 16) and shadowPassTextureUnits[shaderName] or nil if unitscountforthisshader > 0 then tracy.ZoneBeginN("G:CUS:ExecuteDrawPass:ShaderActivate") @@ -2426,7 +2442,7 @@ local function ExecuteDrawPass(drawPass) tracy.ZoneBeginN("G:CUS:ExecuteDrawPass:BindTextures") end for bindPosition, tex in pairs(texAndObj.textures) do - if lastBoundTextures[bindPosition] ~= tex then + if (wantedTextureUnits == nil or wantedTextureUnits[bindPosition]) and lastBoundTextures[bindPosition] ~= tex then gl.Texture(bindPosition, tex) lastBoundTextures[bindPosition] = tex end From fe2e1c4ef3dc7d06749edb94b6bb8dadf2f0d767 Mon Sep 17 00:00:00 2001 From: SethDGamre <165520713+SethDGamre@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:21:03 -0500 Subject: [PATCH 2/3] Zombies - Swarming Update (#8949) **Player Facing Summary** - After 15 minutes into the game, zombies will swarm all teams evenly once they reach 10% of the value of all players combined. - Zombies spawn with XP skewed to the minimum veterancy. - When zombie revive timer has been reset, a purple poof now appears above it. - Units that don't leave corpses like the Fiend will no longer respawn as zombies. - Zombie constructors get a boosted capture range of a minimum of 300. This makes them capable of capturing aircraft. - Zombies now can control aircraft when they're captured or produced. Zombie ai is now moved into its own gadget because it was getting too confusing to have it all in one place. **AI Rework** - reworked the zombie ai to have a persistent move goal that it remembers and falls back to when it's done fighting things. Changes when it gets stuck or some conditions change - zombie aggro is distributed fairly. All zombies are ranked by most to least powerful then random-sequentially picked for all alive allyTeams. - when aggro'd, zombies will move towards a random target's position belonging to the aggro'd allyteam. It'll change targets when it gets close enough. - aggro behavior triggers when zombies have 10% of the value of what all the players combined have. This is to force a desparate survival endgame. This can only happen after 15 minutes **Other Changes** - Zombies will now have a drastically lower chance of spawning with max veterency. It's now biased using disadvantage mechanics (like from DnD) to select the lowest of a few randomly generated numbers between 0.0-1.5. - there's now a little purple "poof" that emits above a corpse after its timer has been reset by reclaiming or resurrecting it a little bit. - fiends will no longer resurrect - constructors now have at least 300 range as zombies. This makes them capable of potentially capturing aircraft. oh yeah, - aircraft can now become zombies and can be controlled as zombies. They'll fly around fighting things. LLM Disclosure: Used Cursor's Grok 4.6, Cursor's auto mode, Fable 5.1 a bit, GPT 5.6 a bit. The AI did what I told it, I tested and reviewed code as I went. I did a final pass checking for hallucinatory stuff and it seemed alright and human readable to me. --- changelog.txt | 9 + luarules/gadgets/ai_zombies.lua | 1220 ++++++++++++++++++++ luarules/gadgets/game_zombies.lua | 1243 ++++++++++++++++++++ luarules/gadgets/unit_zombies.lua | 1752 ----------------------------- 4 files changed, 2472 insertions(+), 1752 deletions(-) create mode 100644 luarules/gadgets/ai_zombies.lua create mode 100644 luarules/gadgets/game_zombies.lua delete mode 100644 luarules/gadgets/unit_zombies.lua diff --git a/changelog.txt b/changelog.txt index 0c50146edb8..dd61dabc5e3 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,12 @@ +# September +• [Scavenger Zombies] + - After 15 minutes into the game, zombies will swarm all teams evenly once they reach 10% of the value of all players combined. + - Zombies spawn with XP skewed to the minimum veterancy so they aren't so tanky so often. + - When zombie revive timer has been reset, a purple poof now appears above it. + - Units that don't leave corpses like the Fiend will no longer respawn as zombies. + - Zombie constructors get a boosted capture range of a minimum of 300. This makes them capable of capturing aircraft. + - Zombies now can control aircraft when they're captured or produced. + # August • [Spectre] 12500 -> 9000 energycost, 165 -> 150 metalcost, 380 -> 450 health • [T1, Seaplane Air Constructors] -35% energycost, -35% buildtime, -35% speed diff --git a/luarules/gadgets/ai_zombies.lua b/luarules/gadgets/ai_zombies.lua new file mode 100644 index 00000000000..2c50eb63eca --- /dev/null +++ b/luarules/gadgets/ai_zombies.lua @@ -0,0 +1,1220 @@ +function gadget:GetInfo() + return { + name = "Zombie AI", + desc = "Controls autonomous Gaia zombie behavior", + author = "SethDGamre", + date = "August 2026", + license = "GNU GPL, v2 or later", + layer = 3, -- after game_zombies.lua + enabled = true, + } +end + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local spring = Spring +local modOptions = spring.GetModOptions() +local modOptionEnabled = modOptions.zombies ~= "disabled" +local isIdleMode = GG.Zombies and GG.Zombies.IdleMode == true or false +if not modOptionEnabled and not isIdleMode then + return false +end + +local random = math.random +local distance2dSquared = math.distance2dSquared +local TAU = 2 * math.pi +local cos = math.cos +local sin = math.sin +local atan2 = math.atan2 +local DEGREES_TO_RADIANS = math.pi / 180 + +local ZOMBIE_ORDER_CHECK_INTERVAL = Game.gameSpeed * 3 +local STUCK_CHECK_INTERVAL = Game.gameSpeed * 12 +local AGGRO_CHECK_INTERVAL = Game.gameSpeed * 30 +local AGGRO_DURATION = Game.gameSpeed * 60 +local AGGRO_MIN_START_FRAME = Game.gameSpeed * 60 * 15 + +local STUCK_DISTANCE = 50 +local STUCK_DISTANCE_SQUARED = STUCK_DISTANCE ^ 2 +local NOGO_ZONE_RADIUS = 600 +local NOGO_ZONE_RADIUS_SQUARED = NOGO_ZONE_RADIUS ^ 2 +local ENEMY_ATTACK_DISTANCE = 1000 +local ORDER_DISTANCE = 1600 +local OBJECTIVE_REACHED_DISTANCE = 200 +local OBJECTIVE_REACHED_DISTANCE_SQUARED = OBJECTIVE_REACHED_DISTANCE ^ 2 +local COMBAT_TARGET_MOVE_REFRESH_DISTANCE = 100 +local COMBAT_TARGET_MOVE_REFRESH_DISTANCE_SQUARED = COMBAT_TARGET_MOVE_REFRESH_DISTANCE ^ 2 +local POSITION_VARIANCE = 50 + +local ZOMBIE_MAX_ORDER_ATTEMPTS = 10 +local ZOMBIE_FACTORY_BUILD_COUNT = 20 +local MAX_NOGO_ZONES = 10 +local AGGRO_ZOMBIE_TO_PLAYER_POWER_RATIO = 0.1 -- the threshold of relative power where zombies stop wandering and swarm players +local COMBAT_ENGAGE_RANGE_RATIO = 0.5 + +local NORMAL_OBJECTIVE_ANGLE_VARIANCE = 90 * DEGREES_TO_RADIANS +local AGGRO_OBJECTIVE_ANGLE_VARIANCE = 22.5 * DEGREES_TO_RADIANS +local COMBAT_SECONDARY_ANGLE_OFFSET = 45 * DEGREES_TO_RADIANS +local COMBAT_SECONDARY_ANGLE_COS = cos(COMBAT_SECONDARY_ANGLE_OFFSET) +local COMBAT_SECONDARY_ANGLE_SIN = sin(COMBAT_SECONDARY_ANGLE_OFFSET) + +local CMD_REPEAT = CMD.REPEAT +local CMD_MOVE_STATE = CMD.MOVE_STATE +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_IDLEMODE = CMD.IDLEMODE +local CMD_MOVE = CMD.MOVE +local CMD_FIGHT = CMD.FIGHT +local CMD_CAPTURE = CMD.CAPTURE +local CMD_STOP = CMD.STOP +local CMD_OPT_SHIFT = { "shift" } + +local FIRE_STATE_FIRE_AT_ALL = 3 +local FIRE_STATE_RETURN_FIRE = 1 +local MOVE_STATE_ROAM = 2 +local IDLEMODE_FLY = 0 +local ENABLE_REPEAT = 1 +local NULL_ATTACKER = -1 +local ENVIRONMENTAL_DAMAGE_ID = Game.envDamageTypes.GroundCollision + +local MAP_SIZE_X = Game.mapSizeX +local MAP_SIZE_Z = Game.mapSizeZ +local MAP_PERIMETER = 2 * (MAP_SIZE_X + MAP_SIZE_Z) +local OBJECTIVE_TYPE_NORMAL = 1 +local OBJECTIVE_TYPE_AGGRO = 2 + +local spGetUnitNearestEnemy = spring.GetUnitNearestEnemy +local spValidUnitID = spring.ValidUnitID +local spGetGroundHeight = spring.GetGroundHeight +local spGetUnitPosition = spring.GetUnitPosition +local spGetUnitDefID = spring.GetUnitDefID +local spGiveOrderToUnit = spring.GiveOrderToUnit +local spGiveOrderArrayToUnit = spring.GiveOrderArrayToUnit +local spGetFactoryCommandCount = spring.GetFactoryCommandCount +local spGetUnitIsDead = spring.GetUnitIsDead +local spGetUnitHealth = spring.GetUnitHealth +local spGetUnitRulesParam = spring.GetUnitRulesParam +local spTestMoveOrder = spring.TestMoveOrder +local spGetUnitCurrentCommand = spring.GetUnitCurrentCommand +local spGetUnitHeight = spring.GetUnitHeight +local spGetUnitTeam = spring.GetUnitTeam +local spGetUnitLosState = spring.GetUnitLosState +local spGetUnitsInCylinder = spring.GetUnitsInCylinder +local spAreTeamsAllied = spring.AreTeamsAllied + +local gaiaTeamID = spring.GetGaiaTeamID() +local gaiaAllyTeamID = select(6, spring.GetTeamInfo(gaiaTeamID)) +local readAsGaia = { ctrl = gaiaTeamID, read = gaiaTeamID, select = gaiaTeamID } +local scavTeamID +for _, teamID in ipairs(spring.GetTeamList()) do + local teamLuaAI = spring.GetTeamLuaAI(teamID) + if teamLuaAI and string.find(teamLuaAI, "ScavengersAI", 1, true) then + scavTeamID = teamID + break + end +end + +local ordersEnabled = true +local isPacified = false +local autoOrdersSuspended = false +local gameFrame = 0 +local totalMobileZombiePower = 0 +local aggroExpirationTimestamp = 0 + +local mobileUnitDefs = {} +local aircraftUnitDefs = {} +local factoriesWithCombatOptions = {} +local unitDefWeaponRanges = {} +local capturingUnits = {} +local zombieAggros = {} +local allyTeamUnits = {} +local unitAllyTeamIDs = {} +local unitAllyTeamIndices = {} +local zombieWatch = {} +local flyingUnits = {} +local zombieOrderBuckets = {} +local zombieStuckBuckets = {} + +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.canCapture then + capturingUnits[unitDefID] = true + end + + if unitDef.weapons and #unitDef.weapons > 0 then + local maximumGroundWeaponRange = 0 + local maximumAirWeaponRange = 0 + local maximumUnderwaterWeaponRange = 0 + + for weaponIndex = 1, #unitDef.weapons do + local weapon = unitDef.weapons[weaponIndex] + local weaponDefID = weapon.weaponDef + if weaponDefID then + local weaponDef = WeaponDefs[weaponDefID] + if + weaponDef + and weaponDef.range + and weaponDef.range > 0 + and not (weaponDef.customParams and weaponDef.customParams.bogus) + then + local isAAWeapon = weapon.onlyTargets + and weapon.onlyTargets.vtol + and not weapon.onlyTargets.ground + local isUnderwaterOnly = weaponDef.type == "TorpedoLauncher" + + if isAAWeapon then + maximumAirWeaponRange = math.max(maximumAirWeaponRange, weaponDef.range) + elseif isUnderwaterOnly then + maximumUnderwaterWeaponRange = math.max(maximumUnderwaterWeaponRange, weaponDef.range) + else + maximumGroundWeaponRange = math.max(maximumGroundWeaponRange, weaponDef.range) + if weapon.onlyTargets and weapon.onlyTargets.vtol then + maximumAirWeaponRange = math.max(maximumAirWeaponRange, weaponDef.range) + end + if weaponDef.waterWeapon then + maximumUnderwaterWeaponRange = + math.max(maximumUnderwaterWeaponRange, weaponDef.range) + end + end + end + end + end + + if maximumGroundWeaponRange > 0 or maximumAirWeaponRange > 0 or maximumUnderwaterWeaponRange > 0 then + unitDefWeaponRanges[unitDefID] = { + ground = maximumGroundWeaponRange, + air = maximumAirWeaponRange, + underwater = maximumUnderwaterWeaponRange, + } + end + end +end + +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.speed > 0 then + mobileUnitDefs[unitDefID] = true + if unitDef.canFly then + aircraftUnitDefs[unitDefID] = true + end + elseif #unitDef.buildOptions > 0 then + local combatOptions = {} + for optionIndex = 1, #unitDef.buildOptions do + local optionDefID = unitDef.buildOptions[optionIndex] + if unitDefWeaponRanges[optionDefID] then + combatOptions[#combatOptions + 1] = optionDefID + end + end + if #combatOptions > 0 then + factoriesWithCombatOptions[unitDefID] = combatOptions + end + end +end + +for bucketIndex = 1, ZOMBIE_ORDER_CHECK_INTERVAL do + zombieOrderBuckets[bucketIndex] = {} +end + +for bucketIndex = 1, STUCK_CHECK_INTERVAL do + zombieStuckBuckets[bucketIndex] = {} +end + +local function removeZombieFromBucket(unitID, bucket, unitIndex, indexField) + local lastIndex = #bucket + local lastUnitID = bucket[lastIndex] + bucket[unitIndex] = lastUnitID + bucket[lastIndex] = nil + if lastUnitID ~= unitID then + zombieWatch[lastUnitID][indexField] = unitIndex + end +end + +local function unwatchZombie(unitID) + local zombieData = zombieWatch[unitID] + if not zombieData then + return + end + if mobileUnitDefs[zombieData.unitDefID] then + totalMobileZombiePower = totalMobileZombiePower - zombieData.power + end + removeZombieFromBucket(unitID, zombieOrderBuckets[unitID % ZOMBIE_ORDER_CHECK_INTERVAL + 1], zombieData.orderBucketIndex, "orderBucketIndex") + removeZombieFromBucket(unitID, zombieStuckBuckets[unitID % STUCK_CHECK_INTERVAL + 1], zombieData.stuckBucketIndex, "stuckBucketIndex") + zombieWatch[unitID] = nil + zombieAggros[unitID] = nil +end + +local function setAggroExpiration() + aggroExpirationTimestamp = gameFrame + AGGRO_DURATION +end + +local function getActiveZombieAggro(unitID) + if gameFrame >= aggroExpirationTimestamp then + return nil + end + return zombieAggros[unitID] +end + +local function addAllyTeamUnit(unitID, allyTeamID) + if not allyTeamID or unitAllyTeamIDs[unitID] then + return + end + local unitList = allyTeamUnits[allyTeamID] + if not unitList then + unitList = {} + allyTeamUnits[allyTeamID] = unitList + end + local unitIndex = #unitList + 1 + unitList[unitIndex] = unitID + unitAllyTeamIDs[unitID] = allyTeamID + unitAllyTeamIndices[unitID] = unitIndex +end + +local function removeAllyTeamUnit(unitID) + local allyTeamID = unitAllyTeamIDs[unitID] + if not allyTeamID then + return + end + local unitList = allyTeamUnits[allyTeamID] + local unitIndex = unitAllyTeamIndices[unitID] + local lastIndex = #unitList + local lastUnitID = unitList[lastIndex] + unitList[unitIndex] = lastUnitID + unitList[lastIndex] = nil + if lastUnitID ~= unitID then + unitAllyTeamIndices[lastUnitID] = unitIndex + end + unitAllyTeamIDs[unitID] = nil + unitAllyTeamIndices[unitID] = nil +end + +local function isZombie(unitID) + return spGetUnitRulesParam(unitID, "zombie") == 1 +end + +local function issueRandomFactoryBuildOrders(unitID, unitDefID, buildCount) + local combatOptions = factoriesWithCombatOptions[unitDefID] + local buildOrders = {} + for buildIndex = 1, buildCount do + buildOrders[#buildOrders + 1] = { -combatOptions[random(1, #combatOptions)], 0, 0 } + end + spGiveOrderArrayToUnit(unitID, buildOrders) +end + +local function clearUnitOrders(unitID) + if spValidUnitID(unitID) then + spGiveOrderToUnit(unitID, CMD_STOP, {}, {}) + end +end + +local function getWeaponRangeForTarget(attackerDefID, targetID, targetYPosition) + local weaponRanges = unitDefWeaponRanges[attackerDefID] + if not weaponRanges then + return + end + local targetDef = UnitDefs[spGetUnitDefID(targetID)] + local weaponRange + if flyingUnits[targetID] or targetDef.canFly then + weaponRange = weaponRanges.air + elseif targetYPosition + (spGetUnitHeight(targetID) or 0) < 0 then + weaponRange = weaponRanges.underwater + else + weaponRange = weaponRanges.ground + end + if weaponRange and weaponRange > 0 then + return weaponRange + end + return nil +end + +local function isUnitInGaiaLos(unitID) + local losState = spGetUnitLosState(unitID, gaiaAllyTeamID, true) + return losState and losState % 2 == 1 -- raw LOS mask: odd means currently in Gaia sight +end + +local function getCombatTargetData(unitDefID, targetID) + if not targetID or not spValidUnitID(targetID) or spGetUnitIsDead(targetID) then + return + end + local targetTeamID = spGetUnitTeam(targetID) + if not targetTeamID or spAreTeamsAllied(gaiaTeamID, targetTeamID) then + return + end + local targetX, targetY, targetZ = spGetUnitPosition(targetID) + if not targetX then + return + end + local targetDefID = spGetUnitDefID(targetID) + local shouldCapture = capturingUnits[unitDefID] + and UnitDefs[targetDefID].capturable ~= false + and isUnitInGaiaLos(targetID) + local weaponRange = getWeaponRangeForTarget(unitDefID, targetID, targetY) + if shouldCapture or (weaponRange and weaponRange > 0) then + return targetX, targetZ, shouldCapture, weaponRange + end +end + +local function getNearestCombatTarget(unitID, unitDefID) + local nearestEnemyID = spGetUnitNearestEnemy(unitID, ENEMY_ATTACK_DISTANCE, true) + local targetX, targetZ, shouldCapture, weaponRange = getCombatTargetData(unitDefID, nearestEnemyID) + if targetX then + return nearestEnemyID, targetX, targetZ, shouldCapture, weaponRange + end + + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local enemyUnits = CallAsTeam(readAsGaia, spGetUnitsInCylinder, unitX, unitZ, ENEMY_ATTACK_DISTANCE, spring.ENEMY_UNITS) -- nearest-enemy API can return an unshootable unit, so fall back to a cylinder scan + local bestTargetID + local bestTargetX + local bestTargetZ + local bestShouldCapture + local bestWeaponRange + local bestDistanceSquared + for enemyIndex = 1, #enemyUnits do + local enemyID = enemyUnits[enemyIndex] + targetX, targetZ, shouldCapture, weaponRange = getCombatTargetData(unitDefID, enemyID) + if targetX then + local targetDistanceSquared = distance2dSquared(unitX, unitZ, targetX, targetZ) + if not bestDistanceSquared or targetDistanceSquared < bestDistanceSquared then + bestTargetID = enemyID + bestTargetX = targetX + bestTargetZ = targetZ + bestShouldCapture = shouldCapture + bestWeaponRange = weaponRange + bestDistanceSquared = targetDistanceSquared + end + end + end + return bestTargetID, bestTargetX, bestTargetZ, bestShouldCapture, bestWeaponRange +end + +local function setRandomEdgeObjective(zombieData) + local perimeterPosition = random() * MAP_PERIMETER -- map a random perimeter length onto one of the four edges + local objectiveX + local objectiveZ + if perimeterPosition < MAP_SIZE_X then + objectiveX = perimeterPosition + objectiveZ = 0 + elseif perimeterPosition < MAP_SIZE_X + MAP_SIZE_Z then + objectiveX = MAP_SIZE_X + objectiveZ = perimeterPosition - MAP_SIZE_X + elseif perimeterPosition < MAP_SIZE_X * 2 + MAP_SIZE_Z then + objectiveX = MAP_SIZE_X * 2 + MAP_SIZE_Z - perimeterPosition + objectiveZ = MAP_SIZE_Z + else + objectiveX = 0 + objectiveZ = MAP_PERIMETER - perimeterPosition + end + objectiveX = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_X - POSITION_VARIANCE, objectiveX)) + objectiveZ = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_Z - POSITION_VARIANCE, objectiveZ)) + zombieData.objective = { type = OBJECTIVE_TYPE_NORMAL, x = objectiveX, z = objectiveZ } +end + +local function getRandomValidAllyTeamUnit(allyTeamID) + local unitList = allyTeamUnits[allyTeamID] + if not unitList or #unitList == 0 then + return + end + local attemptsRemaining = #unitList + while attemptsRemaining > 0 do + local targetUnitID = unitList[random(1, #unitList)] + if spValidUnitID(targetUnitID) and not spGetUnitIsDead(targetUnitID) then + local targetX, _, targetZ = spGetUnitPosition(targetUnitID) + if targetX then + return targetUnitID, targetX, targetZ + end + end + removeAllyTeamUnit(targetUnitID) + attemptsRemaining = attemptsRemaining - 1 + end +end + +local function setAggroObjective(zombieData, allyTeamID) + local targetUnitID, targetX, targetZ = getRandomValidAllyTeamUnit(allyTeamID) + if not targetUnitID then + return false + end + zombieData.objective = { + type = OBJECTIVE_TYPE_AGGRO, + x = targetX, + z = targetZ, + targetUnitID = targetUnitID, + } + return true +end + +local function getLeastAssignedAlly(eligibleAllies) + local leastAssignedAlly = eligibleAllies[1] + for allyIndex = 2, #eligibleAllies do + local allyData = eligibleAllies[allyIndex] + if + allyData.assignedPower < leastAssignedAlly.assignedPower + or ( + allyData.assignedPower == leastAssignedAlly.assignedPower + and allyData.allyTeamID < leastAssignedAlly.allyTeamID + ) + then + leastAssignedAlly = allyData + end + end + return leastAssignedAlly +end + +local function compareZombiePower(firstZombie, secondZombie) + if firstZombie.power == secondZombie.power then + return firstZombie.unitID < secondZombie.unitID + end + return firstZombie.power > secondZombie.power +end + +local function isObjectiveReached(unitID, objective, unitX, unitZ) + if not unitX then + unitX, _, unitZ = spGetUnitPosition(unitID) + end + if not unitX then + return false + end + return distance2dSquared(unitX, unitZ, objective.x, objective.z) <= OBJECTIVE_REACHED_DISTANCE_SQUARED +end + +local function isAggroObjectiveValid(unitID, objective, allyTeamID) + if + not objective + or objective.type ~= OBJECTIVE_TYPE_AGGRO + or not spValidUnitID(objective.targetUnitID) + or spGetUnitIsDead(objective.targetUnitID) + or unitAllyTeamIDs[objective.targetUnitID] ~= allyTeamID + then + return false + end + return not isObjectiveReached(unitID, objective) +end + +local function assignZombieAggroEvenly() + local playerTeams = GG.PowerLib.PlayerTeams + local teamPowers = GG.PowerLib.TeamPowers + local allyPowers = {} + for teamID in pairs(playerTeams) do + local allyTeamID = select(6, spring.GetTeamInfo(teamID)) + local teamPower = teamPowers[teamID] or 0 + allyPowers[allyTeamID] = (allyPowers[allyTeamID] or 0) + teamPower + end + + local eligibleAllies = {} + local eligibleAlliesByID = {} + for allyTeamID, allyPower in pairs(allyPowers) do + if allyPower > 0 and getRandomValidAllyTeamUnit(allyTeamID) then + local allyData = { + allyTeamID = allyTeamID, + assignedPower = 0, + } + eligibleAllies[#eligibleAllies + 1] = allyData + eligibleAlliesByID[allyTeamID] = allyData + end + end + + local zombiesNeedingAggro = {} + for unitID, zombieData in pairs(zombieWatch) do + if mobileUnitDefs[zombieData.unitDefID] then + local allyTeamID = zombieAggros[unitID] + local allyData = allyTeamID and eligibleAlliesByID[allyTeamID] + if allyData and isAggroObjectiveValid(unitID, zombieData.objective, allyTeamID) then + allyData.assignedPower = allyData.assignedPower + zombieData.power + else + zombieAggros[unitID] = nil + if zombieData.objective and zombieData.objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + clearUnitOrders(unitID) + zombiesNeedingAggro[#zombiesNeedingAggro + 1] = { + unitID = unitID, + power = zombieData.power, + } + end + else + zombieAggros[unitID] = nil + if zombieData.objective and zombieData.objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + end + end + + if #eligibleAllies == 0 then + return + end + + table.sort(zombiesNeedingAggro, compareZombiePower) -- keep valid assignments, then give strongest leftovers to the least-pressured ally + for zombieIndex = 1, #zombiesNeedingAggro do + local pendingZombie = zombiesNeedingAggro[zombieIndex] + local zombieData = zombieWatch[pendingZombie.unitID] + local leastAssignedAlly = getLeastAssignedAlly(eligibleAllies) + if setAggroObjective(zombieData, leastAssignedAlly.allyTeamID) then + zombieAggros[pendingZombie.unitID] = leastAssignedAlly.allyTeamID + leastAssignedAlly.assignedPower = leastAssignedAlly.assignedPower + pendingZombie.power + end + end +end + +local function rememberEnemyDirection(unitID, zombieData, targetX, targetZ) + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local deltaX = targetX - unitX + local deltaZ = targetZ - unitZ + if deltaX == 0 and deltaZ == 0 then + return + end + local xScale = math.huge -- project the enemy bearing out to the map edge for later pursuit + if deltaX > 0 then + xScale = (MAP_SIZE_X - unitX) / deltaX + elseif deltaX < 0 then + xScale = -unitX / deltaX + end + local zScale = math.huge + if deltaZ > 0 then + zScale = (MAP_SIZE_Z - unitZ) / deltaZ + elseif deltaZ < 0 then + zScale = -unitZ / deltaZ + end + local boundaryScale = math.min(xScale, zScale) + zombieData.rememberedObjectiveX = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_X - POSITION_VARIANCE, unitX + deltaX * boundaryScale)) + zombieData.rememberedObjectiveZ = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_Z - POSITION_VARIANCE, unitZ + deltaZ * boundaryScale)) +end + +local function ensureMovementObjective(unitID, zombieData, allyTeamID) + local objective = zombieData.objective -- aggro target, else last-seen enemy edge, else a new map-edge wander + if allyTeamID then + if isAggroObjectiveValid(unitID, objective, allyTeamID) then + return objective, false + end + if objective and objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + if setAggroObjective(zombieData, allyTeamID) then + return zombieData.objective, true + end + if zombieAggros[unitID] == allyTeamID then + zombieAggros[unitID] = nil + end + end + + objective = zombieData.objective + if zombieData.rememberedObjectiveX then + local isRememberedObjective = + objective + and objective.type == OBJECTIVE_TYPE_NORMAL + and objective.x == zombieData.rememberedObjectiveX + and objective.z == zombieData.rememberedObjectiveZ + if isRememberedObjective and isObjectiveReached(unitID, objective) then + zombieData.rememberedObjectiveX = nil + zombieData.rememberedObjectiveZ = nil + zombieData.objective = nil + objective = nil + elseif + not objective + or objective.type ~= OBJECTIVE_TYPE_NORMAL + or objective.x ~= zombieData.rememberedObjectiveX + or objective.z ~= zombieData.rememberedObjectiveZ + then + zombieData.objective = { + type = OBJECTIVE_TYPE_NORMAL, + x = zombieData.rememberedObjectiveX, + z = zombieData.rememberedObjectiveZ, + } + return zombieData.objective, true + else + return objective, false + end + end + + if not objective or objective.type ~= OBJECTIVE_TYPE_NORMAL or isObjectiveReached(unitID, objective) then + setRandomEdgeObjective(zombieData) + return zombieData.objective, true + end + return objective, false +end + +local function isInNoGoZone(zombieData, targetX, targetZ) + for _, zone in ipairs(zombieData.noGoZones) do + local deltaX = targetX - zone.x + local deltaZ = targetZ - zone.z + if deltaX * deltaX + deltaZ * deltaZ < NOGO_ZONE_RADIUS_SQUARED then + return true + end + end + return false +end + +local function isMoveTargetTraversable(unitDefID, targetX, targetY, targetZ) + if aircraftUnitDefs[unitDefID] then + return true + end + return spTestMoveOrder(unitDefID, targetX, targetY, targetZ) +end + +local function getMovementCommand(unitDefID) + if aircraftUnitDefs[unitDefID] then + return CMD_FIGHT + end + return CMD_MOVE +end + +local function getObjectiveMoveTarget(unitDefID, zombieData, objective, originX, originZ) + local deltaX = objective.x - originX + local deltaZ = objective.z - originZ + local objectiveDistance = math.sqrt(deltaX * deltaX + deltaZ * deltaZ) + local objectiveAngle = atan2(deltaZ, deltaX) + local angleVariance = objective.type == OBJECTIVE_TYPE_AGGRO and AGGRO_OBJECTIVE_ANGLE_VARIANCE or NORMAL_OBJECTIVE_ANGLE_VARIANCE + + for attemptIndex = 1, ZOMBIE_MAX_ORDER_ATTEMPTS do + local movementAngle + local movementDistance = math.min(ORDER_DISTANCE, objectiveDistance) + if attemptIndex == ZOMBIE_MAX_ORDER_ATTEMPTS then + movementAngle = random() * TAU -- last try: ignore the objective and pick any passable heading + movementDistance = ORDER_DISTANCE + else + movementAngle = objectiveAngle + (random() * 2 - 1) * angleVariance + end + local candidateTargetX = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_X - POSITION_VARIANCE, originX + movementDistance * cos(movementAngle) + random(-POSITION_VARIANCE, POSITION_VARIANCE))) + local candidateTargetZ = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_Z - POSITION_VARIANCE, originZ + movementDistance * sin(movementAngle) + random(-POSITION_VARIANCE, POSITION_VARIANCE))) + if not isInNoGoZone(zombieData, candidateTargetX, candidateTargetZ) then + local candidateTargetY = spGetGroundHeight(candidateTargetX, candidateTargetZ) + if isMoveTargetTraversable(unitDefID, candidateTargetX, candidateTargetY, candidateTargetZ) then + return candidateTargetX, candidateTargetY, candidateTargetZ + end + end + end +end + +local function issueObjectiveMove(unitID, unitDefID, zombieData, objective) + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + if + zombieData.rememberedObjectiveX + and objective.type == OBJECTIVE_TYPE_NORMAL + and objective.x == zombieData.rememberedObjectiveX + and objective.z == zombieData.rememberedObjectiveZ + and isObjectiveReached(unitID, objective, unitX, unitZ) + then + zombieData.rememberedObjectiveX = nil + zombieData.rememberedObjectiveZ = nil + zombieData.objective = nil + setRandomEdgeObjective(zombieData) + objective = zombieData.objective + end + + local movementCommand = getMovementCommand(unitDefID) + local firstTargetX, firstTargetY, firstTargetZ = + getObjectiveMoveTarget(unitDefID, zombieData, objective, unitX, unitZ) + if firstTargetX then + spGiveOrderToUnit(unitID, movementCommand, { firstTargetX, firstTargetY, firstTargetZ }, 0) + local secondTargetX, secondTargetY, secondTargetZ = + getObjectiveMoveTarget(unitDefID, zombieData, objective, firstTargetX, firstTargetZ) -- pre-queue the next hop so they don't stall between order ticks + if secondTargetX then + spGiveOrderToUnit( + unitID, + movementCommand, + { secondTargetX, secondTargetY, secondTargetZ }, + CMD_OPT_SHIFT + ) + end + return + end + + clearUnitOrders(unitID) + if objective.type == OBJECTIVE_TYPE_AGGRO or not zombieData.rememberedObjectiveX then + zombieData.objective = nil + ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + end +end + +local function issueCombatMove(unitID, unitDefID, weaponRange, targetX, targetZ, zombieData) + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + clearUnitOrders(unitID) + return + end + local deltaX = unitX - targetX + local deltaZ = unitZ - targetZ + local distance = math.sqrt(deltaX * deltaX + deltaZ * deltaZ) + if distance == 0 then + clearUnitOrders(unitID) + return + end + local desiredRange = weaponRange * COMBAT_ENGAGE_RANGE_RATIO + local targetMoveX = targetX + deltaX / distance * desiredRange + local targetMoveZ = targetZ + deltaZ / distance * desiredRange + if targetMoveX < 0 or targetMoveX > MAP_SIZE_X or targetMoveZ < 0 or targetMoveZ > MAP_SIZE_Z then + zombieData.combatTargetID = nil + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + clearUnitOrders(unitID) + local fallbackObjective = ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + issueObjectiveMove(unitID, unitDefID, zombieData, fallbackObjective) + return + end + local targetMoveY = spGetGroundHeight(targetMoveX, targetMoveZ) + local isTargetMoveValid = isMoveTargetTraversable(unitDefID, targetMoveX, targetMoveY, targetMoveZ) + if not isTargetMoveValid then + zombieData.combatTargetID = nil + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + clearUnitOrders(unitID) + local fallbackObjective = ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + issueObjectiveMove(unitID, unitDefID, zombieData, fallbackObjective) + return + end + local movementCommand = getMovementCommand(unitDefID) + spGiveOrderToUnit(unitID, movementCommand, { targetMoveX, targetMoveY, targetMoveZ }, 0) + local radialX = targetMoveX - targetX -- queue a 45° orbit around the target so they don't halt at engage range + local radialZ = targetMoveZ - targetZ + local rotationDirection = random() < 0.5 and -1 or 1 + local issuedSecondaryMove = false + for attemptIndex = 1, 2 do + local signedSin = COMBAT_SECONDARY_ANGLE_SIN * rotationDirection + local secondaryTargetX = + targetX + radialX * COMBAT_SECONDARY_ANGLE_COS - radialZ * signedSin + local secondaryTargetZ = + targetZ + radialX * signedSin + radialZ * COMBAT_SECONDARY_ANGLE_COS + if + secondaryTargetX >= 0 + and secondaryTargetX <= MAP_SIZE_X + and secondaryTargetZ >= 0 + and secondaryTargetZ <= MAP_SIZE_Z + then + local secondaryTargetY = spGetGroundHeight(secondaryTargetX, secondaryTargetZ) + if isMoveTargetTraversable(unitDefID, secondaryTargetX, secondaryTargetY, secondaryTargetZ) then + spGiveOrderToUnit( + unitID, + movementCommand, + { secondaryTargetX, secondaryTargetY, secondaryTargetZ }, + CMD_OPT_SHIFT + ) + issuedSecondaryMove = true + break + end + end + rotationDirection = -rotationDirection + end + if not issuedSecondaryMove then + local secondaryTargetX = targetX + radialX * COMBAT_ENGAGE_RANGE_RATIO + local secondaryTargetZ = targetZ + radialZ * COMBAT_ENGAGE_RANGE_RATIO + local secondaryTargetY = spGetGroundHeight(secondaryTargetX, secondaryTargetZ) + if isMoveTargetTraversable(unitDefID, secondaryTargetX, secondaryTargetY, secondaryTargetZ) then + spGiveOrderToUnit( + unitID, + movementCommand, + { secondaryTargetX, secondaryTargetY, secondaryTargetZ }, + CMD_OPT_SHIFT + ) + end + end + zombieData.lastCombatTargetX = targetX + zombieData.lastCombatTargetZ = targetZ +end + +local function updateOrders(unitID, unitDefID) + local zombieData = zombieWatch[unitID] + if mobileUnitDefs[unitDefID] then + local previousCombatTargetID = zombieData.combatTargetID + local currentCommand = spGetUnitCurrentCommand(unitID) + local movementCommand = getMovementCommand(unitDefID) + if + currentCommand == CMD_CAPTURE -- capture needs LOS, so drop the order if Gaia loses sight + and previousCombatTargetID + and not isUnitInGaiaLos(previousCombatTargetID) + then + zombieData.combatTargetID = nil + clearUnitOrders(unitID) + end + local targetX, targetZ, shouldCapture, weaponRange = + getCombatTargetData(unitDefID, zombieData.combatTargetID) + if not targetX then + zombieData.combatTargetID = nil + end + if not zombieData.combatTargetID and (capturingUnits[unitDefID] or unitDefWeaponRanges[unitDefID]) then + local closestKnownEnemy + closestKnownEnemy, targetX, targetZ, shouldCapture, weaponRange = + getNearestCombatTarget(unitID, unitDefID) + if targetX then + zombieData.combatTargetID = closestKnownEnemy + rememberEnemyDirection(unitID, zombieData, targetX, targetZ) + end + end + + if zombieData.combatTargetID then + if shouldCapture then + if currentCommand ~= CMD_CAPTURE or previousCombatTargetID ~= zombieData.combatTargetID then + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + spGiveOrderToUnit(unitID, CMD_CAPTURE, { zombieData.combatTargetID }, 0) + end + else + local combatTargetMoved = not zombieData.lastCombatTargetX + or distance2dSquared( + targetX, + targetZ, + zombieData.lastCombatTargetX, + zombieData.lastCombatTargetZ + ) + >= COMBAT_TARGET_MOVE_REFRESH_DISTANCE_SQUARED + if + currentCommand ~= movementCommand + or previousCombatTargetID ~= zombieData.combatTargetID + or combatTargetMoved + then + issueCombatMove(unitID, unitDefID, weaponRange, targetX, targetZ, zombieData) + end + end + else + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + local objective, objectiveChanged = ensureMovementObjective( + unitID, + zombieData, + getActiveZombieAggro(unitID) + ) + if + previousCombatTargetID + or objectiveChanged + or currentCommand ~= movementCommand + then + issueObjectiveMove(unitID, unitDefID, zombieData, objective) + end + end + end + + if factoriesWithCombatOptions[unitDefID] then + local factoryCommandCount = spGetFactoryCommandCount(unitID) or 0 + if factoryCommandCount < ZOMBIE_FACTORY_BUILD_COUNT then + issueRandomFactoryBuildOrders( + unitID, + unitDefID, + ZOMBIE_FACTORY_BUILD_COUNT - factoryCommandCount + ) + end + end +end + +local function setZombieStates(unitID, unitDefID) + if factoriesWithCombatOptions[unitDefID] then + spGiveOrderToUnit(unitID, CMD_REPEAT, ENABLE_REPEAT, 0) + end + spGiveOrderToUnit(unitID, CMD_MOVE_STATE, MOVE_STATE_ROAM, 0) + if aircraftUnitDefs[unitDefID] then + spGiveOrderToUnit(unitID, CMD_IDLEMODE, IDLEMODE_FLY, 0) + end + if not isPacified then + spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_FIRE_AT_ALL, 0) + else + spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_RETURN_FIRE, 0) + end + spring.SetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) +end + +local function initializeZombie(unitID, unitDefID) + if zombieWatch[unitID] or (scavTeamID and spring.GetUnitTeam(unitID) == scavTeamID) then + return + end + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local unitPower = UnitDefs[unitDefID].power or 0 + zombieWatch[unitID] = { + unitDefID = unitDefID, + lastX = unitX, + lastZ = unitZ, + noGoZones = {}, + power = unitPower, + } + local zombieData = zombieWatch[unitID] + local orderBucket = zombieOrderBuckets[unitID % ZOMBIE_ORDER_CHECK_INTERVAL + 1] + zombieData.orderBucketIndex = #orderBucket + 1 + orderBucket[zombieData.orderBucketIndex] = unitID + local stuckBucket = zombieStuckBuckets[unitID % STUCK_CHECK_INTERVAL + 1] + zombieData.stuckBucketIndex = #stuckBucket + 1 + stuckBucket[zombieData.stuckBucketIndex] = unitID + if mobileUnitDefs[unitDefID] then + setRandomEdgeObjective(zombieData) + totalMobileZombiePower = totalMobileZombiePower + unitPower + end + setZombieStates(unitID, unitDefID) + if ordersEnabled then + updateOrders(unitID, unitDefID) + end +end + +local function clearAllOrders() + for zombieID in pairs(zombieWatch) do + clearUnitOrders(zombieID) + end +end + +local function pacifyZombies(enabled) + isPacified = enabled + ordersEnabled = not isPacified and not autoOrdersSuspended + if isPacified then + clearAllOrders() + end + local fireState = isPacified and FIRE_STATE_RETURN_FIRE or FIRE_STATE_FIRE_AT_ALL + for zombieID in pairs(zombieWatch) do + if spValidUnitID(zombieID) then + spGiveOrderToUnit(zombieID, CMD_FIRE_STATE, fireState) + end + end +end + +local function hasGameEndExplosionStarted() -- last living ally means the end-game explosion; stop attack orders + if not GG.maxDeathFrame then + return false + end + local livingAllyTeams = 0 + local allyTeamList = spring.GetAllyTeamList() + for allyIndex = 1, #allyTeamList do + local teamList = spring.GetTeamList(allyTeamList[allyIndex]) + local allyTeamIsAlive = false + for teamIndex = 1, #teamList do + local teamID = teamList[teamIndex] + if teamID ~= gaiaTeamID then + local teamLuaAI = spring.GetTeamLuaAI(teamID) + if not (teamLuaAI and (string.find(teamLuaAI, "Scavengers", 1, true) or string.find(teamLuaAI, "Raptors", 1, true))) then + local _, _, isDead = spring.GetTeamInfo(teamID) + if not isDead then + allyTeamIsAlive = true + break + end + end + end + end + if allyTeamIsAlive then + livingAllyTeams = livingAllyTeams + 1 + if livingAllyTeams > 1 then + return false + end + end + end + return true +end + +local function suspendAutoOrders(enabled) + autoOrdersSuspended = enabled + ordersEnabled = not isPacified and not autoOrdersSuspended + if autoOrdersSuspended then + clearAllOrders() + end +end + +local function aggroAllZombiesToAllyTeam(allyTeamID) + local markedAny = false + for zombieID in pairs(zombieWatch) do + local zombieData = zombieWatch[zombieID] + if spValidUnitID(zombieID) and mobileUnitDefs[zombieData.unitDefID] then + clearUnitOrders(zombieID) + zombieAggros[zombieID] = allyTeamID + markedAny = true + else + zombieAggros[zombieID] = nil + if zombieData.objective and zombieData.objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + end + end + + if markedAny then + setAggroExpiration() + end + return markedAny +end + +local function aggroTeamID(teamID) + local _, _, isDead, _, _, allyTeamID = spring.GetTeamInfo(teamID) + if isDead ~= false or not allyTeamID then + return false + end + return aggroAllZombiesToAllyTeam(allyTeamID) +end + +local function aggroAllyID(allyTeamID) + local allyTeams = spring.GetTeamList(allyTeamID) + if not allyTeams or #allyTeams == 0 then + return false + end + return aggroAllZombiesToAllyTeam(allyTeamID) +end + +local function killAllZombies() + for zombieID in pairs(zombieWatch) do + if spValidUnitID(zombieID) and not spGetUnitIsDead(zombieID) then + local currentHealth = spGetUnitHealth(zombieID) + if currentHealth and currentHealth > 0 then + spring.AddUnitDamage(zombieID, currentHealth, 0, NULL_ATTACKER, ENVIRONMENTAL_DAMAGE_ID) + end + end + end +end + +local function updateAggro() + if gameFrame % AGGRO_CHECK_INTERVAL ~= 1 or gameFrame < AGGRO_MIN_START_FRAME then + return + end + local totalPlayerPower = GG.PowerLib.TotalPlayerTeamsPower() + local powerCheckSucceeded = totalMobileZombiePower > totalPlayerPower * AGGRO_ZOMBIE_TO_PLAYER_POWER_RATIO + if powerCheckSucceeded then + assignZombieAggroEvenly() + setAggroExpiration() + else + zombieAggros = {} + aggroExpirationTimestamp = 0 + end +end + +local function updateZombieOrders() + local orderBucket = zombieOrderBuckets[gameFrame % ZOMBIE_ORDER_CHECK_INTERVAL + 1] + local bucketIndex = 1 + while bucketIndex <= #orderBucket do + local unitID = orderBucket[bucketIndex] + local zombieData = zombieWatch[unitID] + local unitDefID = zombieData.unitDefID + if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then + unwatchZombie(unitID) + else + updateOrders(unitID, unitDefID) + bucketIndex = bucketIndex + 1 + end + end +end + +local function updateStuckZombies() + local stuckBucket = zombieStuckBuckets[gameFrame % STUCK_CHECK_INTERVAL + 1] + local bucketIndex = 1 + while bucketIndex <= #stuckBucket do + local unitID = stuckBucket[bucketIndex] + local zombieData = zombieWatch[unitID] + if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then + unwatchZombie(unitID) + else + local unitX, _, unitZ = spGetUnitPosition(unitID) + if unitX then + local unitDefID = zombieData.unitDefID + local objective = zombieData.objective + local movedDistanceSquared = distance2dSquared(unitX, unitZ, zombieData.lastX, zombieData.lastZ) + local isAtRememberedObjective = zombieData.rememberedObjectiveX + and objective + and objective.type == OBJECTIVE_TYPE_NORMAL + and objective.x == zombieData.rememberedObjectiveX + and objective.z == zombieData.rememberedObjectiveZ + and isObjectiveReached(unitID, objective, unitX, unitZ) + if + mobileUnitDefs[unitDefID] -- if they haven't moved, blacklist this spot and reroute; keep a remembered-enemy goal + and not isAtRememberedObjective + and movedDistanceSquared < STUCK_DISTANCE_SQUARED + then + clearUnitOrders(unitID) + zombieData.combatTargetID = nil + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + if + objective + and (objective.type == OBJECTIVE_TYPE_AGGRO or not zombieData.rememberedObjectiveX) + then + zombieData.objective = nil + end + if not isInNoGoZone(zombieData, unitX, unitZ) then + if #zombieData.noGoZones >= MAX_NOGO_ZONES then + table.remove(zombieData.noGoZones, 1) + end + table.insert(zombieData.noGoZones, { x = unitX, z = unitZ }) + end + local recoveryObjective = ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + issueObjectiveMove(unitID, unitDefID, zombieData, recoveryObjective) + end + zombieData.lastX = unitX + zombieData.lastZ = unitZ + end + bucketIndex = bucketIndex + 1 + end + end +end + +function gadget:Initialize() + gameFrame = spring.GetGameFrame() + for _, unitID in ipairs(spring.GetAllUnits()) do + local unitTeam = spring.GetUnitTeam(unitID) + local allyTeamID = select(6, spring.GetTeamInfo(unitTeam)) + addAllyTeamUnit(unitID, allyTeamID) + if unitTeam == gaiaTeamID and isZombie(unitID) then + initializeZombie(unitID, spGetUnitDefID(unitID)) + end + end + + GG.ZombieAI = { + InitializeZombie = initializeZombie, + PacifyZombies = pacifyZombies, + SuspendAutoOrders = suspendAutoOrders, + AggroTeamID = aggroTeamID, + AggroAllyID = aggroAllyID, + KillAllZombies = killAllZombies, + ClearAllOrders = clearAllOrders, + } +end + +function gadget:Shutdown() + GG.ZombieAI = nil +end + +function gadget:GameFrame(frame) + gameFrame = frame + if not isPacified and hasGameEndExplosionStarted() then + pacifyZombies(true) + end + updateAggro() + if ordersEnabled then + updateZombieOrders() + updateStuckZombies() + end +end + +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + local allyTeamID = select(6, spring.GetTeamInfo(unitTeam)) + addAllyTeamUnit(unitID, allyTeamID) +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + if unitTeam == gaiaTeamID and isZombie(unitID) then + initializeZombie(unitID, unitDefID) + end +end + +function gadget:UnitDestroyed(unitID) + flyingUnits[unitID] = nil + unwatchZombie(unitID) + removeAllyTeamUnit(unitID) +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam) + removeAllyTeamUnit(unitID) + if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then + return + end + local newAllyTeamID = select(6, spring.GetTeamInfo(newTeam)) + addAllyTeamUnit(unitID, newAllyTeamID) + if newTeam == gaiaTeamID and isZombie(unitID) then + initializeZombie(unitID, unitDefID) + else + unwatchZombie(unitID) + end +end + +function gadget:UnitEnteredAir(unitID) + flyingUnits[unitID] = true +end + +function gadget:UnitLeftAir(unitID) + flyingUnits[unitID] = nil +end diff --git a/luarules/gadgets/game_zombies.lua b/luarules/gadgets/game_zombies.lua new file mode 100644 index 00000000000..db5d506e04f --- /dev/null +++ b/luarules/gadgets/game_zombies.lua @@ -0,0 +1,1243 @@ +function gadget:GetInfo() + return { + name = "Zombies", + desc = "Resurrects corpses as Scavengers or hostile Gaia Zombies", + author = "SethDGamre, code snippets/inspiration from Rafal", + date = "March 2024", + license = "GNU GPL, v2 or later", + layer = 2, -- after game_team_resources.lua (to override resources) and ai_ruins.lua (to overwrite gaia unit cap) + enabled = true, + } +end + +-- To customize zombie respawn time, use customParams.zombie_respawn_time (seconds): +-- < 0 never respawn as a zombie +-- 0 respawn instantly +-- > 0 custom respawn delay in seconds +-- this overrides default timing based on unit power, difficulty, and gamestate. + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local spring = Spring +local modOptions = spring.GetModOptions() +local modOptionEnabled = modOptions.zombies ~= "disabled" +local isIdleMode = GG.Zombies and GG.Zombies.IdleMode == true or false +if not modOptionEnabled and not isIdleMode then + return false +end + +local WARNING_TIME = Game.gameSpeed * 15 -- Frames to start warning before reanimation +local TIMER_NEAR_MAX_THRESHOLD = Game.gameSpeed * 5 -- skip the tamper sparkle if the spawn timer is still near its maximum +local ZOMBIE_UNIT_CAP_FLOOR = 2000 +local ZOMBIE_REZ_FRAME_PARAM = "zombie_rez_frame" +local WAS_ZOMBIE_PARAM = "wasZombie" +local PUBLIC_RULES_PARAM_ACCESS = { public = true } +local WAS_ZOMBIE_TIMEOUT_FRAMES = Game.gameSpeed * 3 +local MIN_CAPTURE_DISTANCE_BOOST = 300 +local MIN_ZOMBIE_XP = 0.25 +local ZOMBIE_MAX_XP = 1.5 + +local standardTechToRezPowerSpeeds = { + [0.5] = 1, + [1] = 1, + [1.5] = 3, + [2] = 8, + [2.5] = 25, + [3] = 42, + [3.5] = 63, + [4] = 83, + [4.5] = 104, +} + +local harderTechToRezPowerSpeeds = { + [0.5] = 1, + [1] = 2, + [1.5] = 5, + [2] = 12, + [2.5] = 38, + [3] = 64, + [3.5] = 86, + [4] = 108, + [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, + rezMin = 90, + rezMax = 180, + countMin = 1, + countMax = 1, + zombieCorpses = false, + }, + hard = { + techToRezPowerSpeeds = harderTechToRezPowerSpeeds, + rezMin = 60, + rezMax = 180, + countMin = 1, + countMax = 1, + zombieCorpses = false, + }, + nightmare = { + techToRezPowerSpeeds = harderTechToRezPowerSpeeds, + rezMin = 60, + rezMax = 120, + countMin = 2, + countMax = 6, + zombieCorpses = false, + }, + akumu = { + techToRezPowerSpeeds = harderTechToRezPowerSpeeds, + rezMin = 60, + rezMax = 120, + countMin = 2, + countMax = 8, + zombieCorpses = true, + }, +} + +---@type ZombieMode +local currentZombieMode = "normal" +local currentZombieConfig = zombieModeConfigs.normal + +local ZOMBIE_CHECK_INTERVAL = Game.gameSpeed -- How often (in frames) everything else is checked +local REZ_SPEED_UPDATE_INTERVAL = Game.gameSpeed * 60 +local WATER_DAMAGE_DEF_ID = Game.envDamageTypes.Water +local CORPSE_RESET_CEG = "selfrepair-sparks-purple" +local CORPSE_RESET_CEG_HEIGHT = 15 +local UNAUTHORIZED_TEXT = "You are not authorized to use zombie commands" --i18n library doesn't exist in gadget space. +local spValidUnitID = spring.ValidUnitID +local spGetGroundHeight = spring.GetGroundHeight +local spGetUnitPosition = spring.GetUnitPosition +local spGetFeaturePosition = spring.GetFeaturePosition +local spGetFeatureResurrect = spring.GetFeatureResurrect +local spGetUnitDefID = spring.GetUnitDefID +local spGetUnitHealth = spring.GetUnitHealth +local spGetUnitRulesParam = spring.GetUnitRulesParam +local spSpawnCEG = spring.SpawnCEG +local random = math.random +local floor = math.floor +local clamp = math.clamp +local ceil = math.ceil + +local teams = spring.GetTeamList() +local scavTeamID +local gaiaTeamID = spring.GetGaiaTeamID() +for _, teamID in ipairs(teams) do + local teamLuaAI = spring.GetTeamLuaAI(teamID) + if teamLuaAI and string.find(teamLuaAI, "ScavengersAI") then + scavTeamID = teamID + end +end + +local gameFrame = 0 +local adjustedRezPowerSpeed = currentZombieConfig.techToRezPowerSpeeds[1] +local currentTechLevel = nil +local autoSpawningEnabled = true + +local zombiesBeingBuilt = {} +local zombieCorpseDefs = {} +local corpseCheckFrames = {} +local corpsesData = {} +local wereZombies = {} +local pendingUnitXp = {} +local pendingZombieCaptures = {} +local heapingZombies = {} +local zombieHeapDefs = {} +local unitDefs = UnitDefs +local unitDefNames = UnitDefNames +local featureDefNames = FeatureDefNames +local featureDefs = FeatureDefs + +local warningEffects = { + "scavmist", + "scavradiation-lightning", +} +local spawnEffects = { + "xploelc2", + "xploelc3", +} + +for unitDefID, unitDef in pairs(unitDefs) do + local corpseDefName = unitDef.corpse + if featureDefNames[corpseDefName] then + local corpseDefID = featureDefNames[corpseDefName].id + local corpseFeatureDef = featureDefs[corpseDefID] + if corpseFeatureDef.resurrectable ~= 0 then + local corpseDefData = { unitDefID = unitDefID } + local customRespawnTime = tonumber(unitDef.customParams and unitDef.customParams.zombie_respawn_time) + if customRespawnTime then + if customRespawnTime < 0 then + corpseDefData.neverRespawn = true + else + corpseDefData.customRespawnTime = customRespawnTime + end + end + zombieCorpseDefs[corpseDefID] = corpseDefData + end + + local zombieDefData = {} + local deathExplosionName = unitDef.deathExplosion + local explosionDefID = WeaponDefNames[deathExplosionName].id + zombieDefData.explosionDefID = explosionDefID + + local heapDefName = corpseFeatureDef.deathFeatureID + if heapDefName then + zombieDefData.heapDefID = heapDefName + end + + zombieHeapDefs[unitDefID] = zombieDefData + end + +end + +local function isZombie(unitID) + return spGetUnitRulesParam(unitID, "zombie") == 1 +end + +local function setGaiaStorage() + local metalStorageToSet = 1000000 + local energyStorageToSet = 1000000 + + local _, currentMetalStorage = spring.GetTeamResources(gaiaTeamID, "metal") + if currentMetalStorage and currentMetalStorage < metalStorageToSet then + spring.SetTeamResource(gaiaTeamID, "ms", metalStorageToSet) + end + + local _, currentEnergyStorage = spring.GetTeamResources(gaiaTeamID, "energy") + if currentEnergyStorage and currentEnergyStorage < energyStorageToSet then + spring.SetTeamResource(gaiaTeamID, "es", energyStorageToSet) + end +end + +local function getUnitRezPower(unitDef) + return math.max(1, unitDef.power or 1) +end + +local function calculateSpawnDelayFrames(unitPower) + local spawnSeconds = floor(unitPower / adjustedRezPowerSpeed) + spawnSeconds = clamp(spawnSeconds, currentZombieConfig.rezMin, currentZombieConfig.rezMax) + return spawnSeconds * Game.gameSpeed +end + +local function getRezPowerSpeedForTechLevel(config, techLevel) + local speeds = config.techToRezPowerSpeeds + if speeds[techLevel] then + return speeds[techLevel] + end + return speeds[1] +end + +local function rebuildZombieCorpseSpawnDelays() + for _, corpseDefData in pairs(zombieCorpseDefs) do + if corpseDefData.neverRespawn then + corpseDefData.spawnDelayFrames = nil + elseif corpseDefData.customRespawnTime then + corpseDefData.spawnDelayFrames = floor(corpseDefData.customRespawnTime * Game.gameSpeed) + else + local unitDef = unitDefs[corpseDefData.unitDefID] + corpseDefData.spawnDelayFrames = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) + end + end +end + +local function updateAdjustedRezPowerSpeed() + local techLevel = 1 + adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) + if GG.PowerLib and GG.PowerLib.HighestPlayerTeamPower and GG.PowerLib.TechGuesstimate then + local highestPowerData = GG.PowerLib.HighestPlayerTeamPower() + techLevel = GG.PowerLib.TechGuesstimate(highestPowerData.power) + adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) + end + + currentTechLevel = techLevel +end + +local function updateRezSpeed() + updateAdjustedRezPowerSpeed() + 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 + + currentZombieMode = mode + currentZombieConfig = config + + updateRezSpeed() +end + +local function calculateHealthRatio(featureID) + local partialReclaimRatio = 1 + local damagedReductionRatio = 1 + local currentMetal, maxMetal = spring.GetFeatureResources(featureID) + if currentMetal and maxMetal and currentMetal ~= 0 and maxMetal ~= 0 then + partialReclaimRatio = currentMetal / maxMetal + end + local health, maxHealth = spring.GetFeatureHealth(featureID) + if health and maxHealth and health ~= 0 and maxHealth ~= 0 then + damagedReductionRatio = health / maxHealth + end + local healthRatio = (partialReclaimRatio + damagedReductionRatio) * 0.5 --average the two ratios to skew the result towards maximum health + return healthRatio +end + +local function warningCEG(featureID, x, y, z) + local radius = spring.GetFeatureRadius(featureID) + + local selectedEffect = warningEffects[random(#warningEffects)] + if selectedEffect == "scavradiation-lightning" and GG.SpawnEnvironmentalLightning then + GG.SpawnEnvironmentalLightning("scavradiation", x, y, z) + else + spSpawnCEG(selectedEffect, x, y, z, 0, 0, 0, radius * 0.25) + end + spSpawnCEG("scaspawn-trail", x, y, z, 0, 0, 0, radius) +end + +local function playSpawnSound(x, y, z) + local selectedEffect = spawnEffects[random(#spawnEffects)] + spring.PlaySoundFile(selectedEffect, 0.5, x, y, z, 0) +end + +local function setCorpseRezRulesParam(featureID, spawnFrame) + spring.SetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, spawnFrame, PUBLIC_RULES_PARAM_ACCESS) +end + +local function clearCorpseRezRulesParam(featureID) + spring.SetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, nil, PUBLIC_RULES_PARAM_ACCESS) +end + +local function wasZombieCorpse(featureID, corpseData) + if corpseData and corpseData.wasZombie then + return true + end + local wasZombieParam = spring.GetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM) + return wasZombieParam == 1 +end + +local function resetSpawn(featureID, featureData, featureX, featureZ) + local newFrame = featureData.tamperedFrame + featureData.spawnDelayFrames + featureData.spawnFrame = newFrame + featureData.creationFrame = featureData.tamperedFrame + featureData.tamperedFrame = nil -- reclaim/rez progress restarts the spawn timer from this frame + setCorpseRezRulesParam(featureID, newFrame) + corpseCheckFrames[newFrame] = corpseCheckFrames[newFrame] or {} + corpseCheckFrames[newFrame][#corpseCheckFrames[newFrame] + 1] = featureID + spSpawnCEG( + CORPSE_RESET_CEG, + featureX, + spGetGroundHeight(featureX, featureZ) + CORPSE_RESET_CEG_HEIGHT, + featureZ, + 0, + 0, + 0 + ) +end + +local function getScavVariantUnitDefID(unitDefID) + local unitDef = unitDefs[unitDefID] + if string.find(unitDef.name, "_scav") then + return unitDefID + end + + local scavUnitDefName = unitDef.name .. "_scav" + local scavUnitDef = unitDefNames[scavUnitDefName] + return scavUnitDef and scavUnitDef.id or unitDefID +end + +local function initializeZombieAI(unitID, unitDefID) + if GG.ZombieAI then + GG.ZombieAI.InitializeZombie(unitID, unitDefID) + end +end + +local function applyZombieBuildRangeBonus(unitID, unitDefID) + local unitDef = unitDefs[unitDefID] + local originalBuildDistance = unitDef and unitDef.buildDistance + if not originalBuildDistance or originalBuildDistance <= 0 then + return + end + local losRadius = unitDef.losRadius or unitDef.sightDistance or 0 + local boostedBuildDistance = math.max(originalBuildDistance, MIN_CAPTURE_DISTANCE_BOOST) + spring.SetUnitBuildParams(unitID, "buildDistance", boostedBuildDistance) +end + +local function restoreOriginalBuildRange(unitID, unitDefID) + local unitDef = unitDefs[unitDefID] + local originalBuildDistance = unitDef and unitDef.buildDistance + if not originalBuildDistance or originalBuildDistance <= 0 then + return + end + spring.SetUnitBuildParams(unitID, "buildDistance", originalBuildDistance) +end + +local function rollSpawnCount() + return random(currentZombieConfig.countMin, currentZombieConfig.countMax) +end + +local function calculateSpawnCount(unitDefID) + local countMin = currentZombieConfig.countMin + local countMax = currentZombieConfig.countMax + if countMin == countMax then + return countMin + end + + local unitDef = unitDefs[unitDefID] + local rezTimeSeconds = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) / Game.gameSpeed + local rezMin = currentZombieConfig.rezMin + local rezMax = currentZombieConfig.rezMax + + if currentTechLevel <= 1 then + return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) -- extra min() rolls skew the count down except for cheap, fast-rez units + end + + if rezTimeSeconds == rezMin then + return rollSpawnCount() + end + if rezTimeSeconds == rezMax then + return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) + end + return math.min(rollSpawnCount(), rollSpawnCount()) +end + +local function spawnZombies(featureID, unitDefID, healthReductionRatio, x, y, z, wasZombie, pastXp) + local unitDef = unitDefs[unitDefID] + local spawnCount = 1 -- dead zombies never multiply, so they can't snowball + if not wasZombie and unitDef.speed > 0 then + spawnCount = calculateSpawnCount(unitDefID) + end + local size = unitDef.xsize + local unitDefToCreate = getScavVariantUnitDefID(unitDefID) + local sizeCategory = ceil((unitDef.xsize / 2 + unitDef.zsize / 2) / 2) + local sizeName = "small" + if sizeCategory > 4.5 then + sizeName = "huge" + elseif sizeCategory > 3.5 then + sizeName = "large" + elseif sizeCategory > 2.5 then + sizeName = "medium" + elseif sizeCategory > 1.5 then + sizeName = "small" + else + sizeName = "tiny" + end + + if pastXp == nil then + local corpseData = featureID and corpsesData[featureID] + if corpseData then + pastXp = corpseData.pastXp + elseif featureID then + pastXp = spring.GetFeatureRulesParam(featureID, "previous_xp") or 0 + else + pastXp = 0 + end + end + + if featureID then + spring.DestroyFeature(featureID) + corpsesData[featureID] = nil + end + playSpawnSound(x, y, z) + + for i = 1, spawnCount do + local randomX = x + random(-size * spawnCount, size * spawnCount) + local randomZ = z + random(-size * spawnCount, size * spawnCount) + local adjustedY = spGetGroundHeight(randomX, randomZ) + + local unitID = spring.CreateUnit(unitDefToCreate, randomX, adjustedY, randomZ, 0, gaiaTeamID) + if unitID then + spSpawnCEG("scav-spawnexplo-" .. sizeName, randomX, adjustedY, randomZ, 0, 0, 0) + local generatedXp = 0 + if modOptions.zombies ~= "normal" then + generatedXp = math.max(MIN_ZOMBIE_XP, math.min(random() * ZOMBIE_MAX_XP, random() * ZOMBIE_MAX_XP, random() * ZOMBIE_MAX_XP)) -- triple-roll min keeps most extra XP low + end + spring.SetUnitExperience(unitID, math.max(pastXp, generatedXp)) + local unitHealth = spGetUnitHealth(unitID) + spring.SetUnitHealth(unitID, unitHealth * healthReductionRatio) + spring.SetUnitRulesParam(unitID, "zombie", 1) + if scavTeamID then + spring.TransferUnit(unitID, scavTeamID) + else + initializeZombieAI(unitID, unitDefToCreate) + applyZombieBuildRangeBonus(unitID, unitDefToCreate) + end + end + 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 + return + end + + local scavUnitDefID = getScavVariantUnitDefID(unitDefID) + + -- If we need to convert to _scav variant + if scavUnitDefID ~= unitDefID then + local x, y, z = spGetUnitPosition(unitID) + local facing = spring.GetUnitDirection(unitID) + local teamID = spring.GetUnitTeam(unitID) + local newUnitID = spring.CreateUnit(scavUnitDefID, x, y, z, facing, teamID) + if newUnitID then + local health, maxHealth = spGetUnitHealth(unitID) + local originalHealthRatio = health / maxHealth + spring.SetUnitHealth(newUnitID, originalHealthRatio * maxHealth) + local experience = spring.GetUnitExperience(unitID) + spring.SetUnitExperience(newUnitID, experience) + + spring.DestroyUnit(unitID, false, true) + + unitID = newUnitID + unitDefID = scavUnitDefID + end + end + + spring.SetUnitRulesParam(unitID, "zombie", 1) + initializeZombieAI(unitID, unitDefID) + if spring.GetUnitTeam(unitID) == gaiaTeamID then + applyZombieBuildRangeBonus(unitID, unitDefID) + end +end + +function gadget:FeatureBuildStepPost(featureID) + local featureData = corpsesData[featureID] + if featureData then + if not featureData.tamperedFrame then + local remainingFrames = featureData.spawnFrame - gameFrame + if remainingFrames < featureData.spawnDelayFrames - TIMER_NEAR_MAX_THRESHOLD then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if featureX then + spSpawnCEG("scaspawn-trail", featureX, featureY + 15, featureZ, 0, 0, 0) + end + end + end + featureData.tamperedFrame = gameFrame + end +end + +function gadget:GameFrame(frame) + gameFrame = frame + + if frame % REZ_SPEED_UPDATE_INTERVAL == 0 then + updateRezSpeed() + end + + local corpsesToCheck = corpseCheckFrames[frame] + if corpsesToCheck then + for i = 1, #corpsesToCheck do + local featureID = corpsesToCheck[i] + local corpseData = corpsesData[featureID] + local featureX, featureY, featureZ + if corpseData then + featureX, featureY, featureZ = spGetFeaturePosition(featureID) + end + if not featureX then --feature is gone + corpsesData[featureID] = nil + else --feature is still there + local featureDefData = zombieCorpseDefs[corpseData.featureDefID] + if corpseData.tamperedFrame then + resetSpawn(featureID, corpseData, featureX, featureZ) + else + local healthReductionRatio = calculateHealthRatio(featureID) + spawnZombies( + featureID, + featureDefData.unitDefID, + healthReductionRatio, + featureX, + featureY, + featureZ, + corpseData.wasZombie, + corpseData.pastXp + ) + end + end + end + corpseCheckFrames[frame] = nil + end + + if frame % ZOMBIE_CHECK_INTERVAL == 0 then + spring.AddTeamResource(gaiaTeamID, "metal", 1000000) + spring.AddTeamResource(gaiaTeamID, "energy", 1000000) + for unitID, timeoutFrame in pairs(wereZombies) do + if timeoutFrame < frame then + wereZombies[unitID] = nil + end + end + for unitID, xpData in pairs(pendingUnitXp) do + if xpData.timeout < frame then + pendingUnitXp[unitID] = nil + end + end + for featureID, featureData in pairs(corpsesData) do + if featureData.spawnFrame - frame < WARNING_TIME then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if not featureX then --doesn't exist anymore + corpsesData[featureID] = nil + elseif not featureData.tamperedFrame then + warningCEG(featureID, featureX, featureY, featureZ) + end + end + end + end +end + +local function isCorpseResurrectable(featureID) + local resurrectUnitName = spGetFeatureResurrect(featureID) + return resurrectUnitName ~= nil and resurrectUnitName ~= "" +end + +local function queueCorpseForSpawning(featureID, override, wasZombie, pastXp) + if not override and not autoSpawningEnabled then + return + end + + local featureDefID = spring.GetFeatureDefID(featureID) + local corpseDefData = zombieCorpseDefs[featureDefID] + if not corpseDefData or corpseDefData.neverRespawn or not isCorpseResurrectable(featureID) then + return + end + + wasZombie = wasZombie or wasZombieCorpse(featureID) + if pastXp == nil then + local existingCorpseData = corpsesData[featureID] + if existingCorpseData then + pastXp = existingCorpseData.pastXp + else + pastXp = spring.GetFeatureRulesParam(featureID, "previous_xp") or 0 + end + end + + local spawnDelayFrames = corpseDefData.spawnDelayFrames + if spawnDelayFrames == 0 then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if featureX then + local healthReductionRatio = calculateHealthRatio(featureID) + spawnZombies( + featureID, + corpseDefData.unitDefID, + healthReductionRatio, + featureX, + featureY, + featureZ, + wasZombie, + pastXp + ) + end + return + end + + local spawnFrame = gameFrame + spawnDelayFrames + corpsesData[featureID] = { + featureDefID = featureDefID, + spawnDelayFrames = spawnDelayFrames, + creationFrame = gameFrame, + spawnFrame = spawnFrame, + wasZombie = wasZombie, + pastXp = pastXp, + } + setCorpseRezRulesParam(featureID, spawnFrame) + corpseCheckFrames[spawnFrame] = corpseCheckFrames[spawnFrame] or {} + corpseCheckFrames[spawnFrame][#corpseCheckFrames[spawnFrame] + 1] = featureID +end + +function gadget:FeatureCreated(featureID, allyTeam, sourceID) + local wasZombie = false + local pastXp = 0 + if sourceID and wereZombies[sourceID] then + wasZombie = true + wereZombies[sourceID] = nil + spring.SetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM, 1, PUBLIC_RULES_PARAM_ACCESS) + end + if sourceID and pendingUnitXp[sourceID] then + pastXp = pendingUnitXp[sourceID].xp + pendingUnitXp[sourceID] = nil + else + pastXp = spring.GetFeatureRulesParam(featureID, "previous_xp") or 0 + end + queueCorpseForSpawning(featureID, false, wasZombie, pastXp) +end + +function gadget:FeatureDestroyed(featureID, allyTeam) + clearCorpseRezRulesParam(featureID) + corpsesData[featureID] = nil +end + +function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) + if unitTeam == gaiaTeamID and builderID and isZombie(builderID) then + zombiesBeingBuilt[unitID] = true + spring.SetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) + end +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + if unitTeam == gaiaTeamID and zombiesBeingBuilt[unitID] then + zombiesBeingBuilt[unitID] = nil + setZombie(unitID) + end +end + +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) + if zombieHeapDefs[unitDefID] then + pendingUnitXp[unitID] = + { xp = spring.GetUnitExperience(unitID) or 0, timeout = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES } + end + if isZombie(unitID) and currentZombieConfig.zombieCorpses and not heapingZombies[unitID] then + wereZombies[unitID] = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES -- FeatureCreated may land later, so stash zombie-ness for a few seconds + end + heapingZombies[unitID] = nil + pendingZombieCaptures[unitID] = nil + zombiesBeingBuilt[unitID] = nil +end + +function gadget:AllowUnitCaptureStep(builderID, builderTeam, unitID, unitDefID, part) + if isZombie(builderID) then + pendingZombieCaptures[unitID] = true + end + return true +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + if oldTeam == gaiaTeamID and newTeam ~= gaiaTeamID and isZombie(unitID) then + restoreOriginalBuildRange(unitID, unitDefID) + end + if pendingZombieCaptures[unitID] then + pendingZombieCaptures[unitID] = nil + if not isZombie(unitID) then + local unitX, unitY, unitZ = spGetUnitPosition(unitID) + local health, maxHealth = spGetUnitHealth(unitID) + local pastXp = spring.GetUnitExperience(unitID) or 0 + local healthReductionRatio = 1 + if health and maxHealth and maxHealth ~= 0 then + healthReductionRatio = health / maxHealth + end + spring.DestroyUnit(unitID, false, true) + if unitX then + spawnZombies(nil, unitDefID, healthReductionRatio, unitX, unitY, unitZ, false, pastXp) + end + end + end +end + +local function isUnitInLava(unitID) + local _, unitY = spring.GetUnitBasePosition(unitID) + if not unitY then + return false + end + + local lavaLevel = spring.GetGameRulesParam("lavaLevel") + if lavaLevel ~= nil and unitY < lavaLevel then + return true + end + + local waterTypeOverlay = GG.WaterTypeOverlay + if waterTypeOverlay and waterTypeOverlay.isActive() and waterTypeOverlay.getActiveType() == "lava" then + local overlayLevel = waterTypeOverlay.getLevel() + if overlayLevel and unitY < overlayLevel then + return true + end + end + + return false +end + +local function shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) -- water/lava deaths always heap so they can't rez from the fluid + if weaponDefID == WATER_DAMAGE_DEF_ID then + return true + end + if not isUnitInLava(unitID) then + return false + end + if not weaponDefID or weaponDefID < 0 then + return true + end + if not attackerID or attackerID < 0 or not spValidUnitID(attackerID) then + return true + end + return false +end + +local function leaveZombieHeap(unitID, unitDefID, attackerID) + local unitX, unitY, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local defData = zombieHeapDefs[unitDefID] + if not defData then + return + end + heapingZombies[unitID] = true -- eat the killing blow and leave a heap instead of a rez-able wreck + spring.DestroyUnit(unitID, false, true, attackerID) + spring.SpawnExplosion(unitX, unitY, unitZ, 0, 0, 0, { weaponDef = defData.explosionDefID, owner = unitID }) + if defData.heapDefID then + spring.CreateFeature(defData.heapDefID, unitX, unitY, unitZ) + end +end + +function gadget:UnitPreDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, weaponDefID, projectileID, attackerID) + if not isZombie(unitID) then + return + end + local leaveHeap = not currentZombieConfig.zombieCorpses or shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) + if not leaveHeap then + return + end + local health = spGetUnitHealth(unitID) + if damage >= health then + leaveZombieHeap(unitID, unitDefID, attackerID) + 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 = spring.GetFeatureDefID(featureID) + local featureDefData = zombieCorpseDefs[featureDefID] + if featureDefData and not featureDefData.neverRespawn and isCorpseResurrectable(featureID) then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if featureX then + local healthReductionRatio = calculateHealthRatio(featureID) + local corpseData = corpsesData[featureID] + local wasZombie = wasZombieCorpse(featureID, corpseData) + local pastXp = corpseData and corpseData.pastXp + spawnZombies( + featureID, + featureDefData.unitDefID, + healthReductionRatio, + featureX, + featureY, + featureZ, + wasZombie, + pastXp + ) + return true + end + end + end + 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 + queueCorpseForSpawning(featureID, true) + 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) + if GG.ZombieAI then + GG.ZombieAI.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 GG.ZombieAI then + GG.ZombieAI.SuspendAutoOrders(enabled) + end +end + +local function aggroTeamID(teamID) + if GG.ZombieAI then + return GG.ZombieAI.AggroTeamID(teamID) + end + return false +end + +local function aggroAllyID(allyID) + if GG.ZombieAI then + return GG.ZombieAI.AggroAllyID(allyID) + end + return false +end + +local function killAllZombies() + if GG.ZombieAI then + GG.ZombieAI.KillAllZombies() + end +end + +local function clearAllOrders() + if GG.ZombieAI then + GG.ZombieAI.ClearAllOrders() + 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 + queueAllCorpsesForSpawning() + end +end + +---Drops every queued corpse spawn without affecting zombies already raised. +local function clearAllZombieSpawns() + for featureID in pairs(corpsesData) do + clearCorpseRezRulesParam(featureID) + end + corpsesData = {} + corpseCheckFrames = {} +end + +local function isAuthorized(playerID) + if spring.IsCheatingEnabled() then + return true + end + local playername = spring.GetPlayerInfo(playerID) + local accountID = BAR.Utilities.GetAccountID(playerID) + if + ( + _G.permissions.devhelpers + and (_G.permissions.devhelpers[accountID] or (playername and _G.permissions.devhelpers[playername])) + ) + or ( + SYNCED + and SYNCED.permissions.devhelpers + and (SYNCED.permissions.devhelpers[accountID] or (playername and SYNCED.permissions.devhelpers[playername])) + ) + then + return true + end + 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 + end + + local convertedCount = 0 + for _, unitID in ipairs(unitIDs) do + if spValidUnitID(unitID) then + setZombie(unitID) + convertedCount = convertedCount + 1 + end + end + + 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 + + for _, unitID in ipairs(allUnits) do + local unitTeam = spring.GetUnitTeam(unitID) + if unitTeam == gaiaTeamID and not isZombie(unitID) then + setZombie(unitID) + convertedCount = convertedCount + 1 + end + end + + return convertedCount +end + +local function commandSetAllGaiaToZombies(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + local convertedCount = setAllGaiaToZombies() + spring.SendMessageToPlayer(playerID, "Set " .. convertedCount .. " Gaia units as zombies") +end + +local function commandQueueAllCorpsesForReanimation(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + queueAllCorpsesForSpawning() + spring.SendMessageToPlayer(playerID, "Queued all corpses for spawning") +end + +local function commandToggleAutoReanimation(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieautospawn 0|1") + return + end + + local enabled = tonumber(words[1]) + if enabled == nil or (enabled ~= 0 and enabled ~= 1) then + spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") + return + end + + setAutoSpawning(enabled == 1) + spring.SendMessageToPlayer(playerID, "Auto spawning " .. (enabled == 1 and "enabled" or "disabled")) +end + +local function commandPacifyZombies(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiepacify 0|1") + return + end + + local enabled = tonumber(words[1]) + if enabled == nil or (enabled ~= 0 and enabled ~= 1) then + spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") + return + end + + pacifyZombies(enabled == 1) + spring.SendMessageToPlayer(playerID, "Zombies " .. (enabled == 1 and "pacified" or "unpacified")) +end + +local function commandSuspendAutoOrders(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiesuspendorders 0|1") + return + end + + local enabled = tonumber(words[1]) + if enabled == nil or (enabled ~= 0 and enabled ~= 1) then + spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") + return + end + + suspendAutoOrders(enabled == 1) + spring.SendMessageToPlayer(playerID, "Zombie auto-orders " .. (enabled == 1 and "suspended" or "resumed")) +end + +local function commandAggroZombiesToTeam(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroteam ") + return + end + + local targetTeamID = tonumber(words[1]) + if not targetTeamID or targetTeamID < 0 then + spring.SendMessageToPlayer(playerID, "Invalid team ID") + return + end + + local success = aggroTeamID(targetTeamID) + if success then + spring.SendMessageToPlayer(playerID, "Zombies aggroed to team " .. targetTeamID) + else + spring.SendMessageToPlayer(playerID, "Team " .. targetTeamID .. " not found or has no units") + end +end + +local function commandAggroZombiesToAlly(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroally ") + return + end + + local targetAllyID = tonumber(words[1]) + if not targetAllyID or targetAllyID < 0 then + spring.SendMessageToPlayer(playerID, "Invalid ally ID") + return + end + + local success = aggroAllyID(targetAllyID) + if success then + spring.SendMessageToPlayer(playerID, "Zombies aggroed to ally team " .. targetAllyID) + else + spring.SendMessageToPlayer(playerID, "Ally team " .. targetAllyID .. " not found or has no units") + end +end + +local function commandKillAllZombies(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + killAllZombies() + spring.SendMessageToPlayer(playerID, "Killed all zombies") +end + +local function commandClearAllZombieOrders(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + clearAllOrders() + spring.SendMessageToPlayer(playerID, "Cleared zombie orders") +end + +local function commandClearZombieSpawns(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + clearAllZombieSpawns() + 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 + + currentZombieMode = mode + applyZombieModeSettings(mode) + return true +end + +local function getZombieMode() + return currentZombieMode +end + +local function commandSetZombieMode(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiemode normal|hard|nightmare|akumu") + return + end + + local mode = string.lower(words[1]) + if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then + spring.SendMessageToPlayer(playerID, "Invalid mode. Use: normal, hard, nightmare, or akumu") + return + end + + setZombieMode(mode) + spring.SendMessageToPlayer(playerID, "Zombie mode set to " .. mode) +end + +function gadget:Initialize() + local initialMode = modOptions.zombies or "normal" + applyZombieModeSettings(initialMode) + + autoSpawningEnabled = modOptionEnabled and not isIdleMode + + gameFrame = spring.GetGameFrame() + + local units = spring.GetAllUnits() + for _, unitID in ipairs(units) do + if isZombie(unitID) then + setZombie(unitID) + end + end + + if not isIdleMode then + local features = spring.GetAllFeatures() + for _, featureID in ipairs(features) do + gadget:FeatureCreated(featureID, gaiaTeamID) + end + end + + GG.Zombies = { IdleMode = isIdleMode } + GG.Zombies.SetZombie = setZombie + GG.Zombies.ConvertUnitsToZombies = convertUnitsToZombies + GG.Zombies.SetAllGaiaToZombies = setAllGaiaToZombies + GG.Zombies.CreateZombieFromFeature = createZombieFromFeature + GG.Zombies.QueueAllCorpsesForSpawning = queueAllCorpsesForSpawning + GG.Zombies.SetAutoSpawning = setAutoSpawning + GG.Zombies.ClearAllZombieSpawns = clearAllZombieSpawns + GG.Zombies.PacifyZombies = pacifyZombies + GG.Zombies.SuspendAutoOrders = suspendAutoOrders + GG.Zombies.AggroTeamID = aggroTeamID + GG.Zombies.AggroAllyID = aggroAllyID + GG.Zombies.KillAllZombies = killAllZombies + GG.Zombies.ClearAllOrders = clearAllOrders + GG.Zombies.SetZombieMode = setZombieMode + GG.Zombies.GetZombieMode = getZombieMode + + gadgetHandler:AddChatAction("zombiesetallgaia", commandSetAllGaiaToZombies, "Set all Gaia units as zombies") + gadgetHandler:AddChatAction( + "zombiequeueallcorpses", + commandQueueAllCorpsesForReanimation, + "Queue all corpses for spawning" + ) + gadgetHandler:AddChatAction("zombieautospawn", commandToggleAutoReanimation, "Enable/disable auto spawning") + gadgetHandler:AddChatAction("zombieclearspawns", commandClearZombieSpawns, "Clear all queued zombie spawns") + gadgetHandler:AddChatAction("zombiepacify", commandPacifyZombies, "Pacify/unpacify zombies") + gadgetHandler:AddChatAction("zombiesuspendorders", commandSuspendAutoOrders, "Suspend/resume zombie auto-orders") + gadgetHandler:AddChatAction("zombieaggroteam", commandAggroZombiesToTeam, "Make zombies aggro to specific team") + gadgetHandler:AddChatAction("zombieaggroally", commandAggroZombiesToAlly, "Make zombies aggro to entire ally team") + gadgetHandler:AddChatAction("zombiekillall", commandKillAllZombies, "Kill all zombies") + gadgetHandler:AddChatAction("zombieclearallorders", commandClearAllZombieOrders, "Clear allzombie orders") + gadgetHandler:AddChatAction("zombiemode", commandSetZombieMode, "Set zombie mode (normal/hard/nightmare/akumu)") +end + +function gadget:Shutdown() + gadgetHandler:RemoveChatAction("zombiesetallgaia") + gadgetHandler:RemoveChatAction("zombiequeueallcorpses") + gadgetHandler:RemoveChatAction("zombieautospawn") + gadgetHandler:RemoveChatAction("zombieclearspawns") + gadgetHandler:RemoveChatAction("zombiepacify") + gadgetHandler:RemoveChatAction("zombiesuspendorders") + gadgetHandler:RemoveChatAction("zombieaggroteam") + gadgetHandler:RemoveChatAction("zombieaggroally") + gadgetHandler:RemoveChatAction("zombiekillall") + gadgetHandler:RemoveChatAction("zombieclearallorders") + gadgetHandler:RemoveChatAction("zombiemode") +end + +function gadget:GamePreload() + local currentUnitCap = spring.GetTeamMaxUnits(gaiaTeamID) + local newUnitCap = math.max(ZOMBIE_UNIT_CAP_FLOOR, currentUnitCap) + spring.SetTeamMaxUnits(gaiaTeamID, newUnitCap) +end + +function gadget:GameStart() + setGaiaStorage() +end diff --git a/luarules/gadgets/unit_zombies.lua b/luarules/gadgets/unit_zombies.lua deleted file mode 100644 index fbf24a04228..00000000000 --- a/luarules/gadgets/unit_zombies.lua +++ /dev/null @@ -1,1752 +0,0 @@ -function gadget:GetInfo() - return { - name = "Zombies", - desc = "Resurrects corpses as Scavengers or hostile Gaia Zombies", - author = "SethDGamre, code snippets/inspiration from Rafal", - date = "March 2024", - license = "GNU GPL, v2 or later", - layer = 2, -- after game_team_resources.lua - enabled = true, - } -end - --- To customize zombie respawn time, use customParams.zombie_respawn_time (seconds): --- < 0 never respawn as a zombie --- 0 respawn instantly --- > 0 custom respawn delay in seconds --- this overrides default timing based on unit power, difficulty, and gamestate. - -if not gadgetHandler:IsSyncedCode() then - return false -end - -local modOptions = Spring.GetModOptions() - -local ZOMBIE_GUARD_RADIUS = 500 -- Radius for zombies to guard allies -local ZOMBIE_MAX_ORDER_ATTEMPTS = 10 -local ZOMBIE_MAX_ORDERS_ISSUED = 2 -local ZOMBIE_FACTORY_BUILD_COUNT = 20 -local ZOMBIE_GUARD_CHANCE = 0.75 -- Chance a zombie will guard allies -local REFRESH_ORDERS_CHANCE = 0.005 -local WARNING_TIME = Game.gameSpeed * 15 -- Frames to start warning before reanimation -local TIMER_NEAR_MAX_THRESHOLD = Game.gameSpeed * 5 -- Frames to start warning before reanimation -local ZOMBIE_REZ_FRAME_PARAM = "zombie_rez_frame" -local WAS_ZOMBIE_PARAM = "wasZombie" -local PUBLIC_RULES_PARAM_ACCESS = { public = true } -local WAS_ZOMBIE_TIMEOUT_FRAMES = Game.gameSpeed * 3 - -local ZOMBIE_MAX_XP = 2 -- Maximum experience value for zombies, skewed towards median - -local standardTechToRezPowerSpeeds = { - [0.5] = 1, - [1] = 1, - [1.5] = 3, - [2] = 8, - [2.5] = 25, - [3] = 42, - [3.5] = 63, - [4] = 83, - [4.5] = 104, -} - -local harderTechToRezPowerSpeeds = { - [0.5] = 1, - [1] = 2, - [1.5] = 5, - [2] = 12, - [2.5] = 38, - [3] = 64, - [3.5] = 86, - [4] = 108, - [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, - rezMin = 90, - rezMax = 180, - countMin = 1, - countMax = 1, - zombieCorpses = false, - }, - hard = { - techToRezPowerSpeeds = harderTechToRezPowerSpeeds, - rezMin = 60, - rezMax = 180, - countMin = 1, - countMax = 1, - zombieCorpses = false, - }, - nightmare = { - techToRezPowerSpeeds = harderTechToRezPowerSpeeds, - rezMin = 60, - rezMax = 120, - countMin = 2, - countMax = 6, - zombieCorpses = false, - }, - akumu = { - techToRezPowerSpeeds = harderTechToRezPowerSpeeds, - rezMin = 60, - rezMax = 120, - countMin = 2, - countMax = 8, - zombieCorpses = true, - }, -} - ----@type ZombieMode -local currentZombieMode = "normal" -local currentZombieConfig = zombieModeConfigs.normal - -local ZOMBIE_ORDER_CHECK_INTERVAL = Game.gameSpeed * 3 -- How often (in frames) to check if zombies need new orders -local ZOMBIE_CHECK_INTERVAL = Game.gameSpeed -- How often (in frames) everything else is checked -local STUCK_CHECK_INTERVAL = Game.gameSpeed * 12 -- How often (in frames) to check if zombies are stuck -local REZ_SPEED_UPDATE_INTERVAL = Game.gameSpeed * 60 - -local STUCK_DISTANCE = 50 -- How far (in units) a zombie can move before being considered stuck -local MAX_NOGO_ZONES = 10 -- How many no-go zones a zombie can have before being considered stuck -local NOGO_ZONE_RADIUS = 600 -- How far (in units) a no-go zone is -local NOGO_ZONE_RADIUS_SQ = NOGO_ZONE_RADIUS * NOGO_ZONE_RADIUS -local ENEMY_ATTACK_DISTANCE = 1000 -- How far (in units) a zombie will detect and choose to attack an enemy -local ORDER_DISTANCE = 800 -- How far (in units) a zombie moves per order - -local CMD_REPEAT = CMD.REPEAT -local CMD_MOVE_STATE = CMD.MOVE_STATE -local CMD_GUARD = CMD.GUARD -local CMD_FIRE_STATE = CMD.FIRE_STATE -local CMD_MOVE = CMD.MOVE -local CMD_CAPTURE = CMD.CAPTURE -local CMD_FIGHT = CMD.FIGHT -local CMD_OPT_SHIFT = { "shift" } - -local FIRE_STATE_FIRE_AT_ALL = 3 -local FIRE_STATE_RETURN_FIRE = 1 -local MOVE_STATE_HOLD_POSITION = 0 -local ENABLE_REPEAT = 1 -local NULL_ATTACKER = -1 -local ENVIRONMENTAL_DAMAGE_ID = Game.envDamageTypes.GroundCollision -local WATER_DAMAGE_DEF_ID = Game.envDamageTypes.Water -local UNAUTHORIZED_TEXT = "You are not authorized to use zombie commands" --i18n library doesn't exist in gadget space. - -local MAP_SIZE_X = Game.mapSizeX -local MAP_SIZE_Z = Game.mapSizeZ - -local spGetUnitRotation = Spring.GetUnitRotation -local spGetUnitNearestEnemy = Spring.GetUnitNearestEnemy -local spValidUnitID = Spring.ValidUnitID -local spGetGroundHeight = Spring.GetGroundHeight -local spGetUnitPosition = Spring.GetUnitPosition -local spGetUnitBasePosition = Spring.GetUnitBasePosition -local spGetFeaturePosition = Spring.GetFeaturePosition -local spGetGameRulesParam = Spring.GetGameRulesParam -local spCreateUnit = Spring.CreateUnit -local spTransferUnit = Spring.TransferUnit -local spGetUnitDefID = Spring.GetUnitDefID -local spGetUnitTeam = Spring.GetUnitTeam -local spGetAllUnits = Spring.GetAllUnits -local spGetGameFrame = Spring.GetGameFrame -local spGetAllFeatures = Spring.GetAllFeatures -local spGiveOrderToUnit = Spring.GiveOrderToUnit -local spGetUnitCommandCount = Spring.GetUnitCommandCount -local spDestroyFeature = Spring.DestroyFeature -local spGetUnitIsDead = Spring.GetUnitIsDead -local spGiveOrderArrayToUnit = Spring.GiveOrderArrayToUnit -local spGetUnitsInCylinder = Spring.GetUnitsInCylinder -local spSetTeamResource = Spring.SetTeamResource -local spGetUnitHealth = Spring.GetUnitHealth -local spSetUnitHealth = Spring.SetUnitHealth -local spSetUnitRulesParam = Spring.SetUnitRulesParam -local spGetUnitRulesParam = Spring.GetUnitRulesParam -local spSetFeatureRulesParam = Spring.SetFeatureRulesParam -local spGetFeatureRulesParam = Spring.GetFeatureRulesParam -local spGetFeatureDefID = Spring.GetFeatureDefID -local spTestMoveOrder = Spring.TestMoveOrder -local spSpawnCEG = Spring.SpawnCEG -local spGetFeatureResources = Spring.GetFeatureResources -local spGetFeatureHealth = Spring.GetFeatureHealth -local spDestroyUnit = Spring.DestroyUnit -local spGetUnitDirection = Spring.GetUnitDirection -local spCreateFeature = Spring.CreateFeature -local spSpawnExplosion = Spring.SpawnExplosion -local spPlaySoundFile = Spring.PlaySoundFile -local spGetFeatureRadius = Spring.GetFeatureRadius -local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand -local spGetFactoryCommands = Spring.GetFactoryCommands -local spAddTeamResource = Spring.AddTeamResource -local spSetUnitExperience = Spring.SetUnitExperience -local spGetUnitExperience = Spring.GetUnitExperience -local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt -local spGetUnitHeight = Spring.GetUnitHeight -local random = math.random -local distance2dSquared = math.distance2dSquared -local pi = math.pi -local tau = 2 * pi -local cos = math.cos -local sin = math.sin -local floor = math.floor -local clamp = math.clamp -local ceil = math.ceil - -local teams = Spring.GetTeamList() -local scavTeamID -local gaiaTeamID = Spring.GetGaiaTeamID() -for _, teamID in ipairs(teams) do - local teamLuaAI = Spring.GetTeamLuaAI(teamID) - if teamLuaAI and string.find(teamLuaAI, "ScavengersAI") then - scavTeamID = teamID - end -end - -local ordersEnabled = true -local gameFrame = 0 -local adjustedRezPowerSpeed = currentZombieConfig.techToRezPowerSpeeds[1] -local currentTechLevel = nil -local isIdleMode = false -local autoSpawningEnabled = true - -local extraDefs = {} -local factoriesWithCombatOptions = {} -local zombiesBeingBuilt = {} -local zombieCorpseDefs = {} -local zombieWatch = {} -local corpseCheckFrames = {} -local corpsesData = {} -local wereZombies = {} -local pendingUnitXp = {} -local pendingZombieCaptures = {} -local heapingZombies = {} -local zombieHeapDefs = {} -local fightingDefs = {} -local unitDefWithWeaponRanges = {} -local capturingUnits = {} -local aaOnlyUnits = {} -local antiUnderWaterOnlyUnits = {} -local flyingUnits = {} -local unitDefs = UnitDefs -local unitDefNames = UnitDefNames -local featureDefNames = FeatureDefNames -local featureDefs = FeatureDefs - -local warningEffects = { - "scavmist", - "scavradiation-lightning", -} -local spawnEffects = { - "xploelc2", - "xploelc3", -} - -for unitDefID, unitDef in pairs(unitDefs) do - local corpseDefName = unitDef.corpse - if featureDefNames[corpseDefName] then - local corpseDefID = featureDefNames[corpseDefName].id - local corpseDefData = { unitDefID = unitDefID } - local customRespawnTime = tonumber(unitDef.customParams and unitDef.customParams.zombie_respawn_time) - if customRespawnTime then - if customRespawnTime < 0 then - corpseDefData.neverRespawn = true - else - corpseDefData.customRespawnTime = customRespawnTime - end - end - zombieCorpseDefs[corpseDefID] = corpseDefData - - local zombieDefData = {} - local deathExplosionName = unitDef.deathExplosion - local explosionDefID = WeaponDefNames[deathExplosionName].id - zombieDefData.explosionDefID = explosionDefID - - local heapDefName = featureDefs[corpseDefID].deathFeatureID - if heapDefName then - zombieDefData.heapDefID = heapDefName - end - - zombieHeapDefs[unitDefID] = zombieDefData - end - - if unitDef.weapons and #unitDef.weapons > 0 then - for i = 1, #unitDef.weapons do - local weaponDef = WeaponDefs[unitDef.weapons[i].weaponDef] - if weaponDef and weaponDef.range and weaponDef.range > 0 then - unitDefWithWeaponRanges[unitDefID] = weaponDef.range - break - end - end - end - - if unitDef.canFight then - fightingDefs[unitDefID] = true - end - - if unitDef.canRepair then - capturingUnits[unitDefID] = true - end - - if unitDef.weapons and #unitDef.weapons > 0 then - local hasWeapons = false - local allWeaponsAA = true - local allWeaponsUnderwater = true - local hasNonUnderwaterWeapons = false - - for i = 1, #unitDef.weapons do - local weaponDefID = unitDef.weapons[i].weaponDef - if weaponDefID then - local weaponDef = WeaponDefs[weaponDefID] - if - weaponDef - and weaponDef.range - and weaponDef.range > 0 - and not (weaponDef.customParams and weaponDef.customParams.bogus) - then - hasWeapons = true - - local isAAWeapon = false - if unitDef.weapons[i].onlyTargets and unitDef.weapons[i].onlyTargets.vtol then - isAAWeapon = true - end - - local isUnderwaterOnly = weaponDef.waterWeapon or false - - if not isAAWeapon then - allWeaponsAA = false - end - - if not isUnderwaterOnly then - allWeaponsUnderwater = false - hasNonUnderwaterWeapons = true - end - end - end - end - - if hasWeapons and allWeaponsAA then - aaOnlyUnits[unitDefID] = true - end - - if hasWeapons and allWeaponsUnderwater and not hasNonUnderwaterWeapons then - antiUnderWaterOnlyUnits[unitDefID] = true - end - end -end - -for unitDefID, unitDef in pairs(unitDefs) do - extraDefs[unitDefID] = {} - if unitDef.speed > 0 then - extraDefs[unitDefID].isMobile = true - elseif #unitDef.buildOptions > 0 then - local combatOptions = {} - for i = 1, #unitDef.buildOptions do - local optionDefID = unitDef.buildOptions[i] - if unitDefWithWeaponRanges[optionDefID] then - combatOptions[#combatOptions + 1] = optionDefID - end - end - if #combatOptions > 0 then - factoriesWithCombatOptions[unitDefID] = combatOptions - end - end -end - -local function initializeZombie(unitID, unitDefID) - local x, y, z = spGetUnitPosition(unitID) - zombieWatch[unitID] = { unitDefID = unitDefID, lastX = x, lastY = y, lastZ = z, noGoZones = {}, isStuck = false } -end - -local function isZombie(unitID) - local isZombieRulesParam = spGetUnitRulesParam(unitID, "zombie") - return isZombieRulesParam and isZombieRulesParam == 1 -end - -local function setGaiaStorage() - local metalStorageToSet = 1000000 - local energyStorageToSet = 1000000 - - local _, currentMetalStorage = Spring.GetTeamResources(gaiaTeamID, "metal") - if currentMetalStorage and currentMetalStorage < metalStorageToSet then - spSetTeamResource(gaiaTeamID, "ms", metalStorageToSet) - end - - local _, currentEnergyStorage = Spring.GetTeamResources(gaiaTeamID, "energy") - if currentEnergyStorage and currentEnergyStorage < energyStorageToSet then - spSetTeamResource(gaiaTeamID, "es", energyStorageToSet) - end -end - -local function getUnitRezPower(unitDef) - return math.max(1, unitDef.power or 1) -end - -local function calculateSpawnDelayFrames(unitPower) - local spawnSeconds = floor(unitPower / adjustedRezPowerSpeed) - spawnSeconds = clamp(spawnSeconds, currentZombieConfig.rezMin, currentZombieConfig.rezMax) - return spawnSeconds * Game.gameSpeed -end - -local function getRezPowerSpeedForTechLevel(config, techLevel) - local speeds = config.techToRezPowerSpeeds - if speeds[techLevel] then - return speeds[techLevel] - end - return speeds[1] -end - -local function rebuildZombieCorpseSpawnDelays() - for _, corpseDefData in pairs(zombieCorpseDefs) do - if corpseDefData.neverRespawn then - corpseDefData.spawnDelayFrames = nil - elseif corpseDefData.customRespawnTime then - corpseDefData.spawnDelayFrames = floor(corpseDefData.customRespawnTime * Game.gameSpeed) - else - local unitDef = unitDefs[corpseDefData.unitDefID] - if unitDef then - corpseDefData.spawnDelayFrames = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) - end - end - end -end - -local function updateAdjustedRezPowerSpeed() - local techLevel = 1 - adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) - if GG.PowerLib and GG.PowerLib.HighestPlayerTeamPower and GG.PowerLib.TechGuesstimate then - local highestPowerData = GG.PowerLib.HighestPlayerTeamPower() - if highestPowerData and highestPowerData.power then - techLevel = GG.PowerLib.TechGuesstimate(highestPowerData.power) - adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) - end - end - - currentTechLevel = techLevel -end - -local function updateRezSpeed() - updateAdjustedRezPowerSpeed() - 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 - - currentZombieMode = mode - currentZombieConfig = config - - updateRezSpeed() -end - -local function calculateHealthRatio(featureID) - local partialReclaimRatio = 1 - local damagedReductionRatio = 1 - local currentMetal, maxMetal = spGetFeatureResources(featureID) - if currentMetal and maxMetal and currentMetal ~= 0 and maxMetal ~= 0 then - partialReclaimRatio = currentMetal / maxMetal - end - local health, maxHealth = spGetFeatureHealth(featureID) - if health and maxHealth and health ~= 0 and maxHealth ~= 0 then - damagedReductionRatio = health / maxHealth - end - local healthRatio = (partialReclaimRatio + damagedReductionRatio) * 0.5 --average the two ratios to skew the result towards maximum health - return healthRatio -end - ---we use this instead of spGetUnitNearestAlly to make sure the unit is not guarding something on terrain it cannot traverse (like boats/land) -local function GetUnitNearestReachableAlly(unitID, unitDefID, range) - local bestAllyID - local bestDistanceSquared - if spGetUnitIsBeingBuilt(unitID) then - return nil - end - - local x, y, z = spGetUnitPosition(unitID) - if not x or not z then - return nil - end - - local readAsGaia = { ctrl = gaiaTeamID, read = gaiaTeamID, select = gaiaTeamID } - local gaiaUnits = CallAsTeam(readAsGaia, spGetUnitsInCylinder, x, z, range, Spring.ALLY_UNITS) - - for i = 1, #gaiaUnits do - local allyID = gaiaUnits[i] - local allyDefID = spGetUnitDefID(allyID) - local currentCommand = spGetUnitCurrentCommand(allyID) - if - (allyID ~= unitID) - and fightingDefs[allyDefID] - and currentCommand ~= CMD_GUARD - and extraDefs[allyDefID].isMobile - then - local ox, oy, oz = spGetUnitPosition(allyID) - if ox and oy and oz then - local currentDistanceSquared = distance2dSquared(x, z, ox, oz) - if - spTestMoveOrder(unitDefID, ox, oy, oz) - and ((bestDistanceSquared == nil) or (currentDistanceSquared < bestDistanceSquared)) - then - bestAllyID = allyID - bestDistanceSquared = currentDistanceSquared - end - end - end - end - return bestAllyID -end - -local function issueRandomFactoryBuildOrders(unitID, unitDefID) - local combatOptions = factoriesWithCombatOptions[unitDefID] - - if not combatOptions or #combatOptions == 0 then - return - end - - local builds = {} - for i = 1, ZOMBIE_FACTORY_BUILD_COUNT do - builds[#builds + 1] = { -combatOptions[random(1, #combatOptions)], 0, 0 } - end - - if #builds > 0 then - spGiveOrderArrayToUnit(unitID, builds) - end -end - -local function warningCEG(featureID, x, y, z) - local radius = spGetFeatureRadius(featureID) - - local selectedEffect = warningEffects[random(#warningEffects)] - if selectedEffect == "scavradiation-lightning" and GG.SpawnEnvironmentalLightning then - GG.SpawnEnvironmentalLightning("scavradiation", x, y, z) - else - spSpawnCEG(selectedEffect, x, y, z, 0, 0, 0, radius * 0.25) - end - spSpawnCEG("scaspawn-trail", x, y, z, 0, 0, 0, radius) -end - -local function playSpawnSound(x, y, z) - local selectedEffect = spawnEffects[random(#spawnEffects)] - spPlaySoundFile(selectedEffect, 0.5, x, y, z, 0) -end - --- for some reason, engine gives us the LEFT direction as the yaw instead of the forwards direction. This gets and corrects it. -local function getActualForwardsYaw(unitID) - return select(2, spGetUnitRotation(unitID)) + (pi / 2) -end - -local function canAttackTarget(attackerID, attackerDefID, targetID, targetYPosition) - if aaOnlyUnits[attackerDefID] then - local targetDef = unitDefs[targetID] - if targetDef and targetDef.canFly and aaOnlyUnits[attackerDefID] then - return true - end - elseif antiUnderWaterOnlyUnits[attackerDefID] then - if targetYPosition <= 0 then - return true - end - elseif targetYPosition + spGetUnitHeight(targetID) >= 0 and not flyingUnits[targetID] then - return true - end - return false -end - -local function updateOrders(unitID, unitDefID, closestKnownEnemy, currentCommand) - if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then - zombieWatch[unitID] = nil - return - end - local isAlreadyGuarding = currentCommand and currentCommand == CMD_GUARD - local nearAlly - if not closestKnownEnemy and currentCommand ~= CMD_MOVE and not isAlreadyGuarding and fightingDefs[unitDefID] then - nearAlly = GetUnitNearestReachableAlly(unitID, unitDefID, ZOMBIE_GUARD_RADIUS) - end - local weaponRange = unitDefWithWeaponRanges[unitDefID] - local data = zombieWatch[unitID] - - if capturingUnits[unitDefID] and closestKnownEnemy and not data.isStuck then - local enemyDefID = spGetUnitDefID(closestKnownEnemy) - if enemyDefID and unitDefs[enemyDefID].capturable ~= false then - spGiveOrderToUnit(unitID, CMD_CAPTURE, { closestKnownEnemy }, 0) - else - data.isStuck = true - end - elseif not data.isStuck and nearAlly and not closestKnownEnemy and random() < ZOMBIE_GUARD_CHANCE then - spGiveOrderToUnit(unitID, CMD_GUARD, { nearAlly }, 0) - elseif extraDefs[unitDefID].isMobile then - local x, y, z = spGetUnitPosition(unitID) - local ordersIssued = 0 - for attempts = 1, ZOMBIE_MAX_ORDER_ATTEMPTS do - local inNoGoZone = false - local attemptX, attemptY, attemptZ - if not data.isStuck and closestKnownEnemy and weaponRange then - local enemyX, enemyY, enemyZ = spGetUnitPosition(closestKnownEnemy) - if enemyX and canAttackTarget(unitID, unitDefID, closestKnownEnemy, enemyY) then - local CLOSER_VARIANCE = 0.5 - weaponRange = weaponRange * CLOSER_VARIANCE - local dx = x - enemyX - local dz = z - enemyZ - - local distance = math.sqrt(dx * dx + dz * dz) - - if distance > 0 then - local normalizedDx = dx / distance - local normalizedDz = dz / distance - - attemptX = enemyX + normalizedDx * weaponRange - attemptZ = enemyZ + normalizedDz * weaponRange - attemptY = spGetGroundHeight(attemptX, attemptZ) - end - end - closestKnownEnemy = nil - else - if isAlreadyGuarding then - break - end - if data.isStuck or attempts == ZOMBIE_MAX_ORDER_ATTEMPTS then - local randomAngle = random() * tau - attemptX = x + ORDER_DISTANCE * cos(randomAngle) - attemptZ = z + ORDER_DISTANCE * sin(randomAngle) - else - local ANGLE_COMPOUNDER = 1.5 - local biasDirection = (random() > 0.5) and 1 or -1 - local baseAngleOffset = pi / 4 - local angleOffset = baseAngleOffset * (ANGLE_COMPOUNDER ^ (attempts - 1)) - local movementAngle = getActualForwardsYaw(unitID) + (biasDirection * angleOffset) - - attemptX = x + ORDER_DISTANCE * cos(movementAngle) - attemptZ = z + ORDER_DISTANCE * sin(movementAngle) - end - - if attemptX < 0 or attemptX > MAP_SIZE_X or attemptZ < 0 or attemptZ > MAP_SIZE_Z then - data.isStuck = true - end - - if attemptX then - attemptY = spGetGroundHeight(attemptX, attemptZ) - end - end - if attemptX then - for _, zone in ipairs(data.noGoZones) do - local dx = attemptX - zone.x - local dz = attemptZ - zone.z - if (dx * dx + dz * dz) < NOGO_ZONE_RADIUS_SQ then - inNoGoZone = true - break - end - end - end - if attemptX and attemptY then - local POSITION_VARIANCE = 50 - attemptX = attemptX + random(-POSITION_VARIANCE, POSITION_VARIANCE) - attemptZ = attemptZ + random(-POSITION_VARIANCE, POSITION_VARIANCE) - if not inNoGoZone and spTestMoveOrder(unitDefID, attemptX, attemptY, attemptZ) then - spGiveOrderToUnit(unitID, CMD_MOVE, { attemptX, attemptY, attemptZ }, CMD_OPT_SHIFT) - ordersIssued = ordersIssued + 1 - if ordersIssued >= ZOMBIE_MAX_ORDERS_ISSUED then - break - end - end - end - end - end - - if factoriesWithCombatOptions[unitDefID] then - local factoryCommands = spGetFactoryCommands(unitID, -1) or {} - local currentCommandCount = #factoryCommands - if currentCommandCount < ZOMBIE_FACTORY_BUILD_COUNT then - issueRandomFactoryBuildOrders(unitID, unitDefID) - end - end -end - -local function setCorpseRezRulesParam(featureID, spawnFrame) - spSetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, spawnFrame, PUBLIC_RULES_PARAM_ACCESS) -end - -local function clearCorpseRezRulesParam(featureID) - spSetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, nil, PUBLIC_RULES_PARAM_ACCESS) -end - -local function wasZombieCorpse(featureID, corpseData) - if corpseData and corpseData.wasZombie then - return true - end - local wasZombieParam = spGetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM) - return wasZombieParam == 1 -end - -local function resetSpawn(featureID, featureData, featureDefData) - local newFrame = featureData.tamperedFrame + featureData.spawnDelayFrames - featureData.spawnFrame = newFrame - featureData.creationFrame = featureData.tamperedFrame - featureData.tamperedFrame = nil - setCorpseRezRulesParam(featureID, newFrame) - corpseCheckFrames[newFrame] = corpseCheckFrames[newFrame] or {} - corpseCheckFrames[newFrame][#corpseCheckFrames[newFrame] + 1] = featureID -end - -local function getScavVariantUnitDefID(unitDefID) - local unitDef = unitDefs[unitDefID] - if not unitDef then - return unitDefID - end - - if string.find(unitDef.name, "_scav") then - return unitDefID - end - - local scavUnitDefName = unitDef.name .. "_scav" - local scavUnitDef = unitDefNames[scavUnitDefName] - return scavUnitDef and scavUnitDef.id or unitDefID -end - -local function setZombieStates(unitID, unitDefID) - if factoriesWithCombatOptions[unitDefID] then - spGiveOrderToUnit(unitID, CMD_REPEAT, ENABLE_REPEAT, 0) - end - spGiveOrderToUnit(unitID, CMD_MOVE_STATE, MOVE_STATE_HOLD_POSITION, 0) - if ordersEnabled then - spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_FIRE_AT_ALL, 0) - else - spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_RETURN_FIRE, 0) - end - spSetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) -end - -local function rollSpawnCount() - return random(currentZombieConfig.countMin, currentZombieConfig.countMax) -end - -local function calculateSpawnCount(unitDefID) - local countMin = currentZombieConfig.countMin - local countMax = currentZombieConfig.countMax - if countMin == countMax then - return countMin - end - - local unitDef = unitDefs[unitDefID] - if not unitDef then - return countMin - end - - local rezTimeSeconds = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) / Game.gameSpeed - local rezMin = currentZombieConfig.rezMin - local rezMax = currentZombieConfig.rezMax - - if currentTechLevel == nil or currentTechLevel <= 1 then - return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) - end - - if rezTimeSeconds == rezMin then - return rollSpawnCount() - end - if rezTimeSeconds == rezMax then - return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) - end - return math.min(rollSpawnCount(), rollSpawnCount()) -end - -local function spawnZombies(featureID, unitDefID, healthReductionRatio, x, y, z, wasZombie, pastXp) - local unitDef = unitDefs[unitDefID] - local spawnCount = 1 - if not wasZombie and extraDefs[unitDefID].isMobile then - spawnCount = calculateSpawnCount(unitDefID) - end - local size = unitDef.xsize - local unitDefToCreate = getScavVariantUnitDefID(unitDefID) - local sizeCategory = ceil((unitDef.xsize / 2 + unitDef.zsize / 2) / 2) - local sizeName = "small" - if sizeCategory > 4.5 then - sizeName = "huge" - elseif sizeCategory > 3.5 then - sizeName = "large" - elseif sizeCategory > 2.5 then - sizeName = "medium" - elseif sizeCategory > 1.5 then - sizeName = "small" - else - sizeName = "tiny" - end - - if pastXp == nil then - local corpseData = corpsesData[featureID] - if corpseData and corpseData.pastXp ~= nil then - pastXp = corpseData.pastXp - else - pastXp = spGetFeatureRulesParam(featureID, "previous_xp") or 0 - end - end - - spDestroyFeature(featureID) - corpsesData[featureID] = nil - playSpawnSound(x, y, z) - - for i = 1, spawnCount do - local randomX = x + random(-size * spawnCount, size * spawnCount) - local randomZ = z + random(-size * spawnCount, size * spawnCount) - local adjustedY = spGetGroundHeight(randomX, randomZ) - - local unitID = spCreateUnit(unitDefToCreate, randomX, adjustedY, randomZ, 0, gaiaTeamID) - if unitID then - spSpawnCEG("scav-spawnexplo-" .. sizeName, randomX, adjustedY, randomZ, 0, 0, 0) - local generatedXp = 0 - if modOptions.zombies ~= "normal" then - generatedXp = (random() * ZOMBIE_MAX_XP + random() * ZOMBIE_MAX_XP) / 2 - end - spSetUnitExperience(unitID, math.max(pastXp, generatedXp)) - local unitHealth = spGetUnitHealth(unitID) - spSetUnitHealth(unitID, unitHealth * healthReductionRatio) - spSetUnitRulesParam(unitID, "zombie", 1) - if scavTeamID then - spTransferUnit(unitID, scavTeamID) - else - initializeZombie(unitID, unitDefID) - if ordersEnabled then - local closestKnownEnemy = spGetUnitNearestEnemy(unitID, ENEMY_ATTACK_DISTANCE, true) - local currentCommand = spGetUnitCurrentCommand(unitID) - updateOrders(unitID, unitDefToCreate, closestKnownEnemy, currentCommand) - end - setZombieStates(unitID, unitDefID) - end - end - 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 - return - end - - local scavUnitDefID = getScavVariantUnitDefID(unitDefID) - - -- If we need to convert to _scav variant - if scavUnitDefID ~= unitDefID then - local x, y, z = spGetUnitPosition(unitID) - local facing = spGetUnitDirection(unitID) - local teamID = spGetUnitTeam(unitID) - local newUnitID - if x and facing and teamID then - newUnitID = spCreateUnit(scavUnitDefID, x, y, z, facing, teamID) - end - if newUnitID then - local health, maxHealth = spGetUnitHealth(unitID) - if health and maxHealth then - local originalHealthRatio = health / maxHealth - spSetUnitHealth(newUnitID, originalHealthRatio * maxHealth) - end - local experience = spGetUnitExperience(unitID) - spSetUnitExperience(newUnitID, experience) - - spDestroyUnit(unitID, false, true) - - unitID = newUnitID - unitDefID = scavUnitDefID - end - end - - spSetUnitRulesParam(unitID, "zombie", 1) - initializeZombie(unitID, unitDefID) - setZombieStates(unitID, unitDefID) -end - -local function clearUnitOrders(unitID) - if spValidUnitID(unitID) then - spGiveOrderToUnit(unitID, CMD.STOP, {}, {}) - end -end - ----Clears the queued orders of every tracked zombie. -local function clearAllOrders() - for zombieID, _ in pairs(zombieWatch) do - clearUnitOrders(zombieID) - end -end - -function gadget:FeatureBuildStepPost(featureID) - local featureData = corpsesData[featureID] - if featureData then - if not featureData.tamperedFrame then - local remainingFrames = featureData.spawnFrame - gameFrame - if remainingFrames < featureData.spawnDelayFrames - TIMER_NEAR_MAX_THRESHOLD then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if featureX then - spSpawnCEG("scaspawn-trail", featureX, featureY + 15, featureZ, 0, 0, 0) - end - end - end - featureData.tamperedFrame = gameFrame - end -end - -function UnitEnteredAir(unitID) - flyingUnits[unitID] = true -end - -function UnitLeftAir(unitID) - flyingUnits[unitID] = nil -end - -function gadget:GameFrame(frame) - gameFrame = frame - - if frame % REZ_SPEED_UPDATE_INTERVAL == 0 then - updateRezSpeed() - end - - local corpsesToCheck = corpseCheckFrames[frame] - if corpsesToCheck then - for i = 1, #corpsesToCheck do - local featureID = corpsesToCheck[i] - local corpseData = corpsesData[featureID] - local featureX, featureY, featureZ - if corpseData then - featureX, featureY, featureZ = spGetFeaturePosition(featureID) - end - if not featureX then --feature is gone - corpsesData[featureID] = nil - else --feature is still there - local featureDefData = zombieCorpseDefs[corpseData.featureDefID] - if corpseData.tamperedFrame then - resetSpawn(featureID, corpseData, featureDefData) - else - local healthReductionRatio = calculateHealthRatio(featureID) - spawnZombies( - featureID, - featureDefData.unitDefID, - healthReductionRatio, - featureX, - featureY, - featureZ, - corpseData.wasZombie, - corpseData.pastXp - ) - end - end - end - corpseCheckFrames[frame] = nil - end - - if frame % ZOMBIE_CHECK_INTERVAL == 0 then - spAddTeamResource(gaiaTeamID, "metal", 1000000) - spAddTeamResource(gaiaTeamID, "energy", 1000000) - for unitID, timeoutFrame in pairs(wereZombies) do - if timeoutFrame < frame then - wereZombies[unitID] = nil - end - end - for unitID, xpData in pairs(pendingUnitXp) do - if xpData.timeout < frame then - pendingUnitXp[unitID] = nil - end - end - for featureID, featureData in pairs(corpsesData) do - if featureData.spawnFrame - frame < WARNING_TIME then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if not featureX then --doesn't exist anymore - corpsesData[featureID] = nil - elseif not featureData.tamperedFrame then - warningCEG(featureID, featureX, featureY, featureZ) - end - end - end - end - - if frame % ZOMBIE_ORDER_CHECK_INTERVAL == 1 then - for unitID, data in pairs(zombieWatch) do - local unitDefID = data.unitDefID - if spGetUnitIsDead(unitID) or not spValidUnitID(unitID) then - zombieWatch[unitID] = nil - elseif ordersEnabled then - local currentCommand = spGetUnitCurrentCommand(unitID) - local refreshOrders = currentCommand ~= CMD_FIGHT - and currentCommand ~= CMD_CAPTURE - and random() <= REFRESH_ORDERS_CHANCE - - if - refreshOrders - or (currentCommand ~= CMD_FIGHT and currentCommand ~= CMD_GUARD and currentCommand ~= CMD_CAPTURE) - then - local closestKnownEnemy - if capturingUnits[unitDefID] or unitDefWithWeaponRanges[unitDefID] then - closestKnownEnemy = spGetUnitNearestEnemy(unitID, ENEMY_ATTACK_DISTANCE, true) - end - - local shouldUpdateOrders = refreshOrders or closestKnownEnemy - if not shouldUpdateOrders then - local queueSize = spGetUnitCommandCount(unitID) - shouldUpdateOrders = not queueSize or queueSize < ZOMBIE_MAX_ORDERS_ISSUED - end - - if shouldUpdateOrders then - clearUnitOrders(unitID) - updateOrders(unitID, unitDefID, closestKnownEnemy, currentCommand) - end - end - end - end - end - - if frame % STUCK_CHECK_INTERVAL == 0 then - for unitID, data in pairs(zombieWatch) do - if spGetUnitIsDead(unitID) or not spValidUnitID(unitID) then - zombieWatch[unitID] = nil - else - local x, y, z = spGetUnitPosition(unitID) - if x and y and z then - if distance2dSquared(x, z, data.lastX, data.lastZ) < STUCK_DISTANCE then - local BLOCK_CHECK_STEP = 15 - local forwardDirection = getActualForwardsYaw(unitID) - local unitX, unitY, unitZ = x, y, z - local test1X = unitX + BLOCK_CHECK_STEP * cos(forwardDirection) - local test1Z = unitZ + BLOCK_CHECK_STEP * sin(forwardDirection) - local test2X = unitX - BLOCK_CHECK_STEP * cos(forwardDirection) - local test2Z = unitZ - BLOCK_CHECK_STEP * sin(forwardDirection) - local unitDefID = data.unitDefID - if - not spTestMoveOrder(unitDefID, test1X, spGetGroundHeight(test1X, test1Z), test1Z) - or not spTestMoveOrder(unitDefID, test2X, spGetGroundHeight(test2X, test2Z), test2Z) - then - clearUnitOrders(unitID) - data.isStuck = true - local alreadyPresent = false - for _, zone in ipairs(data.noGoZones) do - local dx = x - zone.x - local dz = z - zone.z - if (dx * dx + dz * dz) < NOGO_ZONE_RADIUS_SQ then - alreadyPresent = true - break - end - end - if not alreadyPresent then - if #data.noGoZones > MAX_NOGO_ZONES then - table.remove(data.noGoZones, 1) - end - table.insert(data.noGoZones, { x = x, y = y, z = z }) - end - end - else - data.isStuck = false - end - data.lastX = x - data.lastY = y - data.lastZ = z - end - end - end - end -end - -local function queueCorpseForSpawning(featureID, override, wasZombie, pastXp) - if not override and not autoSpawningEnabled then - return - end - - local featureDefID = spGetFeatureDefID(featureID) - local corpseDefData = zombieCorpseDefs[featureDefID] - if not corpseDefData or corpseDefData.neverRespawn then - return - end - - wasZombie = wasZombie or wasZombieCorpse(featureID) - if pastXp == nil then - local existingCorpseData = corpsesData[featureID] - if existingCorpseData and existingCorpseData.pastXp ~= nil then - pastXp = existingCorpseData.pastXp - else - pastXp = spGetFeatureRulesParam(featureID, "previous_xp") or 0 - end - end - - local spawnDelayFrames = corpseDefData.spawnDelayFrames - if spawnDelayFrames == 0 then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if featureX then - local healthReductionRatio = calculateHealthRatio(featureID) - spawnZombies( - featureID, - corpseDefData.unitDefID, - healthReductionRatio, - featureX, - featureY, - featureZ, - wasZombie, - pastXp - ) - end - return - end - - local spawnFrame = gameFrame + spawnDelayFrames - corpsesData[featureID] = { - featureDefID = featureDefID, - spawnDelayFrames = spawnDelayFrames, - creationFrame = gameFrame, - spawnFrame = spawnFrame, - wasZombie = wasZombie, - pastXp = pastXp, - } - setCorpseRezRulesParam(featureID, spawnFrame) - corpseCheckFrames[spawnFrame] = corpseCheckFrames[spawnFrame] or {} - corpseCheckFrames[spawnFrame][#corpseCheckFrames[spawnFrame] + 1] = featureID -end - -function gadget:FeatureCreated(featureID, allyTeam, sourceID) - local wasZombie = false - local pastXp = 0 - if sourceID and wereZombies[sourceID] then - wasZombie = true - wereZombies[sourceID] = nil - spSetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM, 1, PUBLIC_RULES_PARAM_ACCESS) - end - if sourceID and pendingUnitXp[sourceID] then - pastXp = pendingUnitXp[sourceID].xp - pendingUnitXp[sourceID] = nil - else - pastXp = spGetFeatureRulesParam(featureID, "previous_xp") or 0 - end - queueCorpseForSpawning(featureID, false, wasZombie, pastXp) -end - -function gadget:FeatureDestroyed(featureID, allyTeam) - clearCorpseRezRulesParam(featureID) - corpsesData[featureID] = nil -end - -function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) - if unitTeam == gaiaTeamID and builderID and isZombie(builderID) then - zombiesBeingBuilt[unitID] = true - spSetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) - end -end - -function gadget:UnitFinished(unitID, unitDefID, unitTeam) - if unitTeam == gaiaTeamID then - if isZombie(unitID) then - initializeZombie(unitID, unitDefID) - elseif zombiesBeingBuilt[unitID] then - zombiesBeingBuilt[unitID] = nil - setZombie(unitID) - end - end -end - -function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) - if zombieHeapDefs[unitDefID] then - pendingUnitXp[unitID] = - { xp = spGetUnitExperience(unitID) or 0, timeout = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES } - end - if isZombie(unitID) and currentZombieConfig.zombieCorpses and not heapingZombies[unitID] then - wereZombies[unitID] = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES - end - heapingZombies[unitID] = nil - pendingZombieCaptures[unitID] = nil - flyingUnits[unitID] = nil - zombieWatch[unitID] = nil - zombiesBeingBuilt[unitID] = nil -end - -function gadget:AllowUnitCaptureStep(builderID, builderTeam, unitID, unitDefID, part) - if isZombie(builderID) then - pendingZombieCaptures[unitID] = true - end - return true -end - -function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) - if pendingZombieCaptures[unitID] then - pendingZombieCaptures[unitID] = nil - if not isZombie(unitID) then - setZombie(unitID) - end - end -end - -local function isUnitInLava(unitID) - local _, unitY = spGetUnitBasePosition(unitID) - if not unitY then - return false - end - - local lavaLevel = spGetGameRulesParam("lavaLevel") - if lavaLevel ~= nil and unitY < lavaLevel then - return true - end - - local waterTypeOverlay = GG.WaterTypeOverlay - if waterTypeOverlay and waterTypeOverlay.isActive() and waterTypeOverlay.getActiveType() == "lava" then - local overlayLevel = waterTypeOverlay.getLevel() - if overlayLevel and unitY < overlayLevel then - return true - end - end - - return false -end - -local function shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) - if weaponDefID == WATER_DAMAGE_DEF_ID then - return true - end - if not isUnitInLava(unitID) then - return false - end - if not weaponDefID or weaponDefID < 0 then - return true - end - if not attackerID or attackerID < 0 or not spValidUnitID(attackerID) then - return true - end - return false -end - -local function leaveZombieHeap(unitID, unitDefID, attackerID) - local unitX, unitY, unitZ = spGetUnitPosition(unitID) - if not unitX then - return - end - local defData = zombieHeapDefs[unitDefID] - if not defData then - return - end - heapingZombies[unitID] = true - spDestroyUnit(unitID, false, true, attackerID) - spSpawnExplosion(unitX, unitY, unitZ, 0, 0, 0, { weaponDef = defData.explosionDefID, owner = unitID }) - if defData.heapDefID then - spCreateFeature(defData.heapDefID, unitX, unitY, unitZ) - end -end - -function gadget:UnitPreDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, weaponDefID, projectileID, attackerID) - if not isZombie(unitID) then - return - end - local leaveHeap = not currentZombieConfig.zombieCorpses or shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) - if not leaveHeap then - return - end - local health = spGetUnitHealth(unitID) - if damage >= health then - leaveZombieHeap(unitID, unitDefID, attackerID) - 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) - if zombieCorpseDefs[featureDefID] then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if featureX then - local featureDefData = zombieCorpseDefs[featureDefID] - local healthReductionRatio = calculateHealthRatio(featureID) - local corpseData = corpsesData[featureID] - local wasZombie = wasZombieCorpse(featureID, corpseData) - local pastXp = corpseData and corpseData.pastXp - spawnZombies( - featureID, - featureDefData.unitDefID, - healthReductionRatio, - featureX, - featureY, - featureZ, - wasZombie, - pastXp - ) - return true - end - end - end - 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 - queueCorpseForSpawning(featureID, true) - 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 - fireState = FIRE_STATE_RETURN_FIRE - ordersEnabled = false - clearAllOrders() - else - fireState = FIRE_STATE_FIRE_AT_ALL - ordersEnabled = true - end - for zombieID, _ in pairs(zombieWatch) do - if spValidUnitID(zombieID) then - Spring.GiveOrderToUnit(zombieID, CMD.FIRE_STATE, fireState) - end - 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 - clearAllOrders() - else - ordersEnabled = true - end -end - -local function fightNearTargets(targetUnits) - if not targetUnits or #targetUnits == 0 then - return false - end - - for zombieID, _ in pairs(zombieWatch) do - if spValidUnitID(zombieID) then - local randomTarget = targetUnits[random(1, #targetUnits)] - if spValidUnitID(randomTarget) then - local targetX, targetY, targetZ = spGetUnitPosition(randomTarget) - if targetX then - local angle = random() * tau - local offsetDistance = random(25, 500) - local fightX = targetX + cos(angle) * offsetDistance - local fightZ = targetZ + sin(angle) * offsetDistance - local fightY = spGetGroundHeight(fightX, fightZ) - - Spring.GiveOrderToUnit(zombieID, CMD.FIGHT, { fightX, fightY, fightZ }, {}) - end - end - end - end - - 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() - - local isDead = select(3, Spring.GetTeamInfo(teamID)) - - if isDead or isDead == nil then - return false - end - - local targetUnits = Spring.GetTeamUnits(teamID) or {} - 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() - - local targetUnits = {} - local allyTeams = Spring.GetTeamList(allyID) - - if not allyTeams then - return false - end - - for _, teamID in pairs(allyTeams) do - local unitsToAdd = Spring.GetTeamUnits(teamID) - for _, unitID in pairs(unitsToAdd) do - table.insert(targetUnits, unitID) - end - end - - 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 - local currentHealth = spGetUnitHealth(zombieID) - if currentHealth and currentHealth > 0 then - Spring.AddUnitDamage(zombieID, currentHealth, 0, NULL_ATTACKER, ENVIRONMENTAL_DAMAGE_ID) - end - end - 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 - queueAllCorpsesForSpawning() - end -end - ----Drops every queued corpse spawn without affecting zombies already raised. -local function clearAllZombieSpawns() - for featureID in pairs(corpsesData) do - clearCorpseRezRulesParam(featureID) - end - corpsesData = {} - corpseCheckFrames = {} -end - -local function isAuthorized(playerID) - if Spring.IsCheatingEnabled() then - return true - end - local playername = Spring.GetPlayerInfo(playerID) - local accountID = BAR.Utilities.GetAccountID(playerID) - if - ( - _G - and _G.permissions.devhelpers - and (_G.permissions.devhelpers[accountID] or (playername and _G.permissions.devhelpers[playername])) - ) - or ( - SYNCED - and SYNCED.permissions.devhelpers - and (SYNCED.permissions.devhelpers[accountID] or (playername and SYNCED.permissions.devhelpers[playername])) - ) - then - return true - end - 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 - end - - local convertedCount = 0 - for _, unitID in ipairs(unitIDs) do - if spValidUnitID(unitID) then - setZombie(unitID) - convertedCount = convertedCount + 1 - end - end - - 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 - - for _, unitID in ipairs(allUnits) do - local unitTeam = Spring.GetUnitTeam(unitID) - if unitTeam == gaiaTeamID and not isZombie(unitID) then - setZombie(unitID) - convertedCount = convertedCount + 1 - end - end - - return convertedCount -end - -local function commandSetAllGaiaToZombies(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - local convertedCount = setAllGaiaToZombies() - Spring.SendMessageToPlayer(playerID, "Set " .. convertedCount .. " Gaia units as zombies") -end - -local function commandQueueAllCorpsesForReanimation(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - queueAllCorpsesForSpawning() - Spring.SendMessageToPlayer(playerID, "Queued all corpses for spawning") -end - -local function commandToggleAutoReanimation(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieautospawn 0|1") - return - end - - local enabled = tonumber(words[1]) - if enabled == nil or (enabled ~= 0 and enabled ~= 1) then - Spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") - return - end - - setAutoSpawning(enabled == 1) - Spring.SendMessageToPlayer(playerID, "Auto spawning " .. (enabled == 1 and "enabled" or "disabled")) -end - -local function commandPacifyZombies(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiepacify 0|1") - return - end - - local enabled = tonumber(words[1]) - if enabled == nil or (enabled ~= 0 and enabled ~= 1) then - Spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") - return - end - - pacifyZombies(enabled == 1) - Spring.SendMessageToPlayer(playerID, "Zombies " .. (enabled == 1 and "pacified" or "unpacified")) -end - -local function commandSuspendAutoOrders(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiesuspendorders 0|1") - return - end - - local enabled = tonumber(words[1]) - if enabled == nil or (enabled ~= 0 and enabled ~= 1) then - Spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") - return - end - - suspendAutoOrders(enabled == 1) - Spring.SendMessageToPlayer(playerID, "Zombie auto-orders " .. (enabled == 1 and "suspended" or "resumed")) -end - -local function commandAggroZombiesToTeam(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroteam ") - return - end - - local targetTeamID = tonumber(words[1]) - if not targetTeamID or targetTeamID < 0 then - Spring.SendMessageToPlayer(playerID, "Invalid team ID") - return - end - - local success = aggroTeamID(targetTeamID) - if success then - Spring.SendMessageToPlayer(playerID, "Zombies aggroed to team " .. targetTeamID) - else - Spring.SendMessageToPlayer(playerID, "Team " .. targetTeamID .. " not found or has no units") - end -end - -local function commandAggroZombiesToAlly(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroally ") - return - end - - local targetAllyID = tonumber(words[1]) - if not targetAllyID or targetAllyID < 0 then - Spring.SendMessageToPlayer(playerID, "Invalid ally ID") - return - end - - local success = aggroAllyID(targetAllyID) - if success then - Spring.SendMessageToPlayer(playerID, "Zombies aggroed to ally team " .. targetAllyID) - else - Spring.SendMessageToPlayer(playerID, "Ally team " .. targetAllyID .. " not found or has no units") - end -end - -local function commandKillAllZombies(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - killAllZombies() - Spring.SendMessageToPlayer(playerID, "Killed all zombies") -end - -local function commandClearAllZombieOrders(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - clearAllOrders() - Spring.SendMessageToPlayer(playerID, "Cleared zombie orders") -end - -local function commandClearZombieSpawns(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - clearAllZombieSpawns() - 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 - - currentZombieMode = mode - applyZombieModeSettings(mode) - return true -end - -local function commandSetZombieMode(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiemode normal|hard|nightmare|akumu") - return - end - - local mode = string.lower(words[1]) - if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then - Spring.SendMessageToPlayer(playerID, "Invalid mode. Use: normal, hard, nightmare, or akumu") - return - end - - local success = setZombieMode(mode) - if success then - Spring.SendMessageToPlayer(playerID, "Zombie mode set to " .. mode) - else - Spring.SendMessageToPlayer(playerID, "Failed to set zombie mode to " .. mode) - end -end - -function gadget:Initialize() - local modOptionEnabled = modOptions.zombies ~= "disabled" - isIdleMode = GG.Zombies and GG.Zombies.IdleMode == true or false - - if not modOptionEnabled and not isIdleMode then - gadgetHandler:RemoveGadget(gadget) - return - end - - local initialMode = modOptions.zombies --[[@as ZombieMode?]] or "normal" - applyZombieModeSettings(initialMode) - - autoSpawningEnabled = modOptionEnabled and not isIdleMode - - gameFrame = spGetGameFrame() - - local units = spGetAllUnits() - for _, unitID in ipairs(units) do - if isZombie(unitID) then - setZombie(unitID) - end - end - - if not isIdleMode then - local features = spGetAllFeatures() - for _, featureID in ipairs(features) do - gadget:FeatureCreated(featureID, gaiaTeamID) - end - end - - GG.Zombies = {} - GG.Zombies.SetZombie = setZombie - GG.Zombies.ConvertUnitsToZombies = convertUnitsToZombies - GG.Zombies.SetAllGaiaToZombies = setAllGaiaToZombies - GG.Zombies.CreateZombieFromFeature = createZombieFromFeature - GG.Zombies.QueueAllCorpsesForSpawning = queueAllCorpsesForSpawning - GG.Zombies.SetAutoSpawning = setAutoSpawning - GG.Zombies.ClearAllZombieSpawns = clearAllZombieSpawns - GG.Zombies.PacifyZombies = pacifyZombies - GG.Zombies.SuspendAutoOrders = suspendAutoOrders - GG.Zombies.AggroTeamID = aggroTeamID - GG.Zombies.AggroAllyID = aggroAllyID - 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 - - gadgetHandler:AddChatAction("zombiesetallgaia", commandSetAllGaiaToZombies, "Set all Gaia units as zombies") - gadgetHandler:AddChatAction( - "zombiequeueallcorpses", - commandQueueAllCorpsesForReanimation, - "Queue all corpses for spawning" - ) - gadgetHandler:AddChatAction("zombieautospawn", commandToggleAutoReanimation, "Enable/disable auto spawning") - gadgetHandler:AddChatAction("zombieclearspawns", commandClearZombieSpawns, "Clear all queued zombie spawns") - gadgetHandler:AddChatAction("zombiepacify", commandPacifyZombies, "Pacify/unpacify zombies") - gadgetHandler:AddChatAction("zombiesuspendorders", commandSuspendAutoOrders, "Suspend/resume zombie auto-orders") - gadgetHandler:AddChatAction("zombieaggroteam", commandAggroZombiesToTeam, "Make zombies aggro to specific team") - gadgetHandler:AddChatAction("zombieaggroally", commandAggroZombiesToAlly, "Make zombies aggro to entire ally team") - gadgetHandler:AddChatAction("zombiekillall", commandKillAllZombies, "Kill all zombies") - gadgetHandler:AddChatAction("zombieclearallorders", commandClearAllZombieOrders, "Clear allzombie orders") - gadgetHandler:AddChatAction("zombiemode", commandSetZombieMode, "Set zombie mode (normal/hard/nightmare/akumu)") -end - -function gadget:Shutdown() - gadgetHandler:RemoveChatAction("zombiesetallgaia") - gadgetHandler:RemoveChatAction("zombiequeueallcorpses") - gadgetHandler:RemoveChatAction("zombieautospawn") - gadgetHandler:RemoveChatAction("zombieclearspawns") - gadgetHandler:RemoveChatAction("zombiepacify") - gadgetHandler:RemoveChatAction("zombiesuspendorders") - gadgetHandler:RemoveChatAction("zombieaggroteam") - gadgetHandler:RemoveChatAction("zombieaggroally") - gadgetHandler:RemoveChatAction("zombiekillall") - gadgetHandler:RemoveChatAction("zombieclearallorders") - gadgetHandler:RemoveChatAction("zombiemode") -end - -function gadget:GameStart() - setGaiaStorage() -end From da09265374ba43e0579a78c500de1c440c7583e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ptaszek?= Date: Wed, 9 Sep 2026 15:34:04 +0200 Subject: [PATCH 3/3] Terraform Brush 1.14: sculpting cadence, INFLUENCE bands, editor sandbox (#9087) Branch: `tf-brush-improvements-14` Continues the Terraform Brush work. Two threads this time. The first is the campaign terrain tutorial MrBob recorded for the art team: watching a whole map get sculpted with one brush highlighted some potential improvements in handing brush strokes. The second is a batch of asks from using the editor on actual maps: soft influence bands for texturing, sun presets that survive a project load, an Open Project browser that copes with a cloned maps repository, and editor sessions that stop spawning commanders into the canvas. ## Features **FOLLOW STROKE.** A chip in SHAPE turns the brush shape to the direction you are dragging, so a square, hexagon or triangle leaves a ribbon with its flat sides along the path instead of a chain of same-angle stamps. The angle is smoothed over the last few dabs so mouse jitter cannot spin the shape, and quantised to 2 degrees, the step the brush's own falloff cache keys on. The protractor snaps it to the spoke grid when both are on, the brush ring shows the angle that will land, and your own rotation comes back when the stroke ends. Offered for the sculpt modes only: the other tools sharing the SHAPE row stamp rather than stroke. A **CLAY SCULPT** preset ships the whole setup MrBob makes a map with: clay on, intensity at the floor, the sharpest falloff, square with FOLLOW. **PASSABILITY overlay.** Every DISPLAY row gains a chip that tints ground steeper than a move class in the engine's impassable purple, so cliff height can be judged while sculpting without selecting a unit and pressing F6. Clicking cycles BOT, VEH, HOVER, AMPH and off, and the slopes are read off those units' real movedefs so the band matches what F6 draws. Needs the tileset shader. **INFLUENCE bands in SURFACE and LAYERS.** An altitude band and a slope band, each with a feather, that scale a stroke instead of cutting it the way the FILTERS do, so a texture does more of its thing in the lowlands or on the flats and fades out beyond them. SURFACE remembers a profile per texture (it follows the texture across slots and biome swaps, projects keep it in `surface.lua`, and Copy to all stamps it onto every slot); LAYERS keeps one per channel. Erasing is never scaled, and the Ctrl sneak peek shows the band, so what you see is what lands. **SURFACE SCATTER.** Position, size and strength jitter per stamp, on top of SPACING. With spacing near one and a half brush widths, one drag lays the hand-placed dot field the texturing pass wants instead of a solid band. The DOT preset now carries a generous falloff and the scatter, which is the texturing brush from the tutorial: dots with gaps, never fills. **Sun & Shadows PRESETS.** Three sun-only times of day (Canonical, Dusk, Overcast: direction, intensity, sun colours and shadow densities, nothing else), the harvested map moods the New Map wizard offers, and your own saved files. Save writes the whole live environment under a name to `Terraform Brush/Environments/`, so a look carries to every map and session; a SUN ONLY / FULL ENVIRONMENT switch decides what a click applies. The sun direction also gains AZIMUTH and ELEVATION sliders next to the vector rows. **Open Project browser.** A search box (matching the name, the folder path or the NxN size), RECENT / NAME / SIZE sort chips and a folder tree. Projects may live in subfolders of `MapProjects/` up to four levels deep, and a name may contain "/" to save into one, so a git clone of a maps repository placed inside `MapProjects/` lists as it is on disk and pulls straight into the browser. The date column reads as an age, and a project saved this session lists even while the engine's folder snapshot cannot see it yet. **AUTOMATIC DEPOSIT.** A SURFACE variant slot the shader claims on its own where wind-blown sand would gather: on the lee side of slopes relative to a wind direction, and in the pockets the intermediary already reads. It only fills ground no stroke claimed, so paint wins, cliffs stay clean, and it is off until a slot is chosen. **METAL SPOTS glow controls and BAND WIDTH.** The glow light gets the LIGHTS tool's point-light controls (intensity, radius, height, colour swatches with a preview bar and RGB sliders), graying out while the glow is off. BAND WIDTH sets how far the silhouette band, the rubble skirt Band layer and Band tone shape, reaches around a spot. Also: per-texture SELECTED SLOT tints in SURFACE > GRADING, SURFACE palette captions that name each texture's role (TINT, NORMAL and so on), `/tf_sunlog` for finding what reset a sun. ## Focus mode An eye button next to the pause button in the panel header. It hides the game interface the way F5 does but leaves the editor alive: the panel, the brush ring, the grid and water overlays and skybox switching all keep working, so a map can be looked at, sculpted and screenshotted without the HUD. RmlUi documents are rendered by the engine outside the hidden-interface gate, so the panel survives on its own; the brush widget reads the focus flag to keep drawing its overlays, and the flag is set with the explicit `hideinterface 1` / `0` forms so it cannot desync from F5. Skybox picks used to wait for the HUD to come back: the widget handler skips `DrawScreen` while the interface is hidden, and that was the one draw call-in the pending skybox apply lived in. The panel's deferred applies (skybox, New Map environment preset, fog-off, forcestart) now drain from `DrawScreenPost`, and the same audit moved two more jobs that would have stalled the same way: the brush widget's heightmap import and export and New Map drive, and the map project widget's Save / Open driver. Exits: the button, closing the panel or deactivating every tool, a `/luaui reload`, and F5 from outside all hand the interface back. Hiding the panel with its hotkey does not, so focus plus a hidden panel is the clean-screenshot setup. A map capture started in focus mode still comes out clean and leaves the interface hidden afterwards. ## Editor auto-open on New Map and Open Project A New Map and an opened project bring the Terraformer up by themselves: the brush arms in RAISE the moment the canvas is playable, at the New Map's forcestart and at the end of a project's load phases, unless a tool is already up. A plain `/luaui reload` on a running canvas does not trigger it. ## WORLDSPACE TINT (WIP) A new section in the Tileset window (after BIOME TINT) that makes world-height tinting one system with a wide look range, from the old three-colour grade to a layered canyon. One shared AXIS feeds every lever: Auto rides the map's height reference, so a flat canvas stays untinted; Manual pins it to elmos and can be tilted along a direction and wobbled. GRADE is the old three stops as colour chips with a movable split. STRATA lays repeating tint beds along the axis (one to eight, period, phase, hardness from a soft crossfade to a knife edge, wobble, per-bed jitter), with BASE / INTER / CLIFF / PLAT chips choosing which layers take them and a colour chip per bed. GRADIENT STOPS is MrBob's ask from Discord: an array of two to eight hue / saturation / value entries spread evenly over the height, in Multiply or Colorize mode. RAMP samples a painted gradient image along the axis (a PNG from `Terraform Brush/Ramps/`, picked from a file list with Rescan / Clear) in Multiply or Tint mode, with a strength and a repeat count. SATURATION scales colour from base to top, TIDE RINGS darkens rings above the waterline, and SNOW LINE whitens the tops above a height with a band, a slope limit and its own colour. Every colour chip (grade stops, beds, gradient stops, snow) selects what one shared COLOUR editor edits: the swatch palette, a preview bar, and Red / Green / Blue plus Hue / Sat / Value sliders that work for every chip whichever way it is stored, so a bed can be dialled in HSV and a stop picked from the RGB palette. Each sub-group has its own RESET and the header RESET takes them all. The chips are painted from the knob table, so a preset, a project load or a console `/tileset set` lands in the UI. The ramp file is saved with the project in `tileset.lua` next to the biome key and applied on Open. Needs the tileset shader (0.27); with the shipped defaults a map renders exactly as before. The section is marked WIP in the panel: it will get more work in a later release. ## IMAGE overlay Every DISPLAY row gains an IMAGE chip: lay a reference image over the whole map, a real coastline to sculpt against or a transparent sheet of guide lines, and paint over it. Images are read from `Terraform Brush/Overlays/` (PNG, JPG, TGA, BMP or DDS; alpha is kept, so only the lines of a transparent sheet show). The gear next to the chip, or a right click on it, opens the shared IMAGE OVERLAY window: the file list and the placement controls, OPACITY, OFFSET X / Y and SCALE sliders that preview live on the ground while you drag, STRETCH or KEEP ASPECT, FLIP H / V and RESET; a left click on the chip toggles the overlay once an image is loaded. The image is projected onto the ground through the map's depth buffer, so it hugs every slope at any map size for one fullscreen pass, and units and features draw over it (it needs `AllowDeferredMapRendering`, the BAR default). The pick and placement survive a reload; `/tfimage ` picks a file from the console, `/tfimage` alone toggles it, `/tfimage off` unloads it. New files: `luaui/Widgets/cmd_terraform_image_overlay.lua` and the two `luaui/Shaders/terraform_image_overlay.*.glsl`. ## Sculpt performance huskyvoiceyui and Moose reported 10 to 15 fps lost while dragging the clay brush on a big map, closer to 8 fps with FOLLOW STROKE on a square brush, and concentric rings on every clay stroke. Four things were behind it. The gadget committed one `SetHeightMapFunc` and one undo entry per dab, up to 48 a tick on footprints overlapping about 85 %, so the engine recalculated the terrain (mip heightmaps, normals, slopes, pathing, LOS, the normal and shading textures, mesh patches) up to 48 times a tick. The falloff-stamp cache keyed the rotation-invariant circle on angle and held four entries, so FOLLOW STROKE, whose angle changes every dab, rebuilt a stamp per dab. The widget forced a mesh re-tessellation for ten frames after every tick, which at 20 Hz is every frame of a drag. And the panel mirrored terraform state at frame rate with about fifty unguarded data-model writes and nineteen raw slider writes. - **A tick's dabs apply as one batch.** The dabs of a `$terraform_stroke$` message work against a copy of the cells they touch and the tick commits once: one heightmap write, one undo entry. Dab k still sees dab k-1's writes. A stub-engine harness fed both versions the same strokes: heightmaps identical to float noise, engine recalcs and undo entries per stroke down 6x, Lua time down about 3x, undoing a stroke 4x faster. - **Stamp cache.** Circle and ring are keyed angle-free, the cache holds stamps by a 3M-cell budget instead of four slots, and only very large shaped stamps quantise the angle coarser (2 degrees up to 40k cells, up to 30 degrees beyond), so a FOLLOW drag at big radius cannot rebuild a 100k-cell stamp per dab. - **Mesh refresh** is armed for two frames from the engine's own heightmap-update event, i.e. once per landed edit, instead of ten frames per brush tick. - **Panel** mirrors terraform state on every 4th frame while the brush is down on the world (frame rate again the moment the mouse is over it), and its per-frame slider writes go through the dirty-check helper. - **Performance mode** (Settings > General, persisted) for big maps and slower machines: wider dab spacing where the falloff can take it (a quarter radius for soft curves and clay, a fifth up to curve 2.0, unchanged above), 32 dabs per tick instead of 48, 6-degree FOLLOW steps, and the panel mirror strided all the time. Its description names the two free levers, pausing the game and Focus mode. - **Clay rings, at the root.** The clay plane was the live centre height plus the layer step, re-measured every tick, so from the second tick on it sat on the disc the previous tick had just raised: every tick stacked another disc one layer up, and the disc edges read as rings (a slow 40-tick drag at intensity 10 piled 2700 elmos). The plane now comes from the pre-stroke surface, averaged over the dab centre and four taps half a radius out, so every dab of a stroke agrees on one plane and the stroke lays exactly one layer of INTENSITY x 8 elmos. Settings > Stroke > Clay build-up restores the old stacking for anyone who wants a held brush to keep piling. ## Improvements - **Sculpt strokes follow the path the mouse actually drew.** The brush sampled the cursor 20 times a second and bridged a straight chord between samples, so a fast scribble arrived with its corners cut and its dabs delivered in visible bursts. Every mouse move is now recorded and the tick walks that polyline, dropping dabs at the brush's own spacing. If the callin never fires, the walk degrades to exactly the old chord. - **Clay strokes deposit per distance travelled, not per tick.** A tick's intensity was divided by the number of dabs it contained, and each dab re-derived its target plane from the already-raised centre, so the deposit fell with the square of the dab count: at the lowest intensity a slow drag laid about 0.08 elmo per dab and a fast tick with 48 dabs about 0.00003. Speed removed the stroke. A whole tick now goes out as one message and the gadget derives every clay plane from the heightmap as it was before the tick, so nothing compounds and the per-tick rise is still bounded by one brush step. It also cuts up to 48 network messages a tick down to one, which the multiplayer co-editing work will want. - **SURFACE brush size steps proportionally** (a tenth of the current size per scroll notch instead of a flat 8 elmos), so a small brush can be tuned. - **SAMPLE buttons on the SURFACE altitude bounds**, like the raise tool's HEIGHT CAP: the next click on the terrain writes that height into the bound, pushes the other bound along if the band would invert, and switches the filter or band on. - Project folders and names may contain spaces; Open Project rows are larger and the window wider. - The sun sliders keep the applied intensity on every nudge, re-assert ground and unit shadow densities separately, and restamp each other so the vector rows and the azimuth/elevation rows never disagree. ## Fixes - **The SURFACE Ctrl sneak peek works at every zoom again.** It only exists in the tileset shader's live path, and 1.13's far cache and clipmap take over the ground as soon as the camera pulls back, so the preview quietly stopped working past a close zoom. A pixel under the brush now takes the live path whatever the distance, and the cache-only shader program stands down while a peek is up. - **The sun config survives a project open and a preset.** Three paths could still lose it: the ENV panel's RESET buttons returned to the lighting the engine held when the panel first opened, which on a canvas is the flat blank-map default; a preset without an intensity forced it back to 1.0; and a skybox fade in flight restored the sun colours it had captured over a config applied mid-fade. - **Editor sessions no longer spawn commanders.** New Map and Open Project start scripts carry an `editor_sandbox` flag; the initial-spawn gadget skips the commander for those sessions and the game-end gadgets stand down on the same flag. Editor canvases also leave pregame on their own a few frames after boot, so terrain raised above the canvas base is clickable without a manual start. - **DISPLAY chip rows wrap** onto a second line instead of squeezing every label onto two lines once a tool has five chips. ## Also - 1.14 changelog section in `doc/TerraformBrush-changelog.md`; header badge bumped to 1.14. - The gadget gains a `$terraform_stroke$` packet carrying a whole brush tick. The per-dab packet is unchanged and still used by sticky-measure replay. - Three gadgets outside the Terraform Brush are touched, all for the commander fix and nothing else: `game_initial_spawn.lua` skips the commander when the start script carries `editor_sandbox`, and `game_end.lua` / `game_team_com_ends.lua` stand down on the same flag so a commanderless editor session is not scored as a loss. Each is a single guarded block. - One whitespace-only commit reindents a block in `game_initial_spawn.lua`. It was already unformatted on master, and the format check only looks at the files a pull request touches, so it stayed green until this branch edited that file. ## Testing Tested locally on generated canvases and real maps with the tileset shader running: fast and slow sculpt strokes with clay at the intensity floor, square and circle with FOLLOW on and off, the protractor and symmetry with FOLLOW, the PASSABILITY band against F6 for a bot and a vehicle, SURFACE scatter and the retuned DOT preset over a sculpt, INFLUENCE bands in both SURFACE and LAYERS including the sneak peek at several zoom levels, sun presets across project loads and skybox fades, the Open Project browser against a cloned maps repository with spaces and subfolders, and editor sessions starting without commanders. ## AI/LLM usage statement Written and implemented with Claude Code under my direction; I reviewed the diff. --------- Co-authored-by: Floris --- doc/TerraformBrush-changelog.md | 50 + doc/TerraformBrush.md | 44 +- luarules/gadgets/cmd_terraform_brush.lua | 575 ++++- luarules/gadgets/game_end.lua | 7 +- luarules/gadgets/game_initial_spawn.lua | 20 +- luarules/gadgets/game_team_com_ends.lua | 5 + .../gui_terraform_brush/env_presets.lua | 5 +- .../gui_terraform_brush.lua | 2005 +++++++++++++++-- .../gui_terraform_brush.rcss | 200 +- .../gui_terraform_brush.rml | 869 ++++++- .../gui_terraform_brush/tf_environment.lua | 184 +- .../gui_terraform_brush/tf_lights.lua | 4 + .../gui_terraform_brush/tf_surface.lua | 122 + .../gui_terraform_brush/tf_tileset.lua | 495 +++- .../Shaders/terraform_image_overlay.frag.glsl | 51 + .../Shaders/terraform_image_overlay.vert.glsl | 18 + luaui/Widgets/cmd_map_project.lua | 299 ++- luaui/Widgets/cmd_splat_painter.lua | 98 +- luaui/Widgets/cmd_terraform_brush.lua | 621 ++++- luaui/Widgets/cmd_terraform_brush_capture.lua | 14 +- luaui/Widgets/cmd_terraform_image_overlay.lua | 403 ++++ luaui/Widgets/cmd_terraform_suite.lua | 1 + luaui/images/terraform_brush/eye.png | Bin 0 -> 1821 bytes luaui/images/terraform_brush/eye_slash.png | Bin 0 -> 2561 bytes 24 files changed, 5574 insertions(+), 516 deletions(-) create mode 100644 luaui/Shaders/terraform_image_overlay.frag.glsl create mode 100644 luaui/Shaders/terraform_image_overlay.vert.glsl create mode 100644 luaui/Widgets/cmd_terraform_image_overlay.lua create mode 100644 luaui/images/terraform_brush/eye.png create mode 100644 luaui/images/terraform_brush/eye_slash.png diff --git a/doc/TerraformBrush-changelog.md b/doc/TerraformBrush-changelog.md index b3d4dbeeb13..6ab98ee75d7 100644 --- a/doc/TerraformBrush-changelog.md +++ b/doc/TerraformBrush-changelog.md @@ -4,6 +4,56 @@ Release history for the Terraform Brush map-editing suite. Version numbers follow the improvements-branch scheme (`tf-brush-improvements-N` up to 1.10, `tf-improvements-N` from 1.11): branch `N` corresponds to release `1.N`. Only versions merged into the upstream Beyond All Reason repository are listed as releases. Intermediate development branches that were folded into a later release are noted separately. +## 1.14 - 2026-09-04 + +### New + +- The Open Project browser gained a search box (matches the name, the folder path or the NxN size), RECENT / NAME / SIZE sort chips and a folder tree. Projects may live in subfolders of MapProjects/ up to four levels deep, and a project name may contain "/" to save into one, so a git clone of a maps repository placed inside MapProjects/ lists as it is on disk and pulls straight into the browser. The date column reads as an age ("3 h ago", "yesterday"); RECENT orders by the last open or save through the editor, not only the last save; and a project saved this session, or freshly cloned and opened once, lists even while the engine's folder snapshot cannot see it yet. + +- Sun & Shadows gained PRESETS (requested by PtaQ and MrBob): six sun-only times of day (Dawn to Overcast: direction, intensity, sun colours and shadow densities, nothing else), the harvested map moods the New Map wizard offers, and your own saved files. Save writes the whole live environment under a name to Terraform Brush/Environments/, so a look carries to every map and session; a SUN ONLY / FULL ENVIRONMENT switch decides what a click applies. The sun direction also has AZIMUTH and ELEVATION sliders next to the vector rows. +- `/tf_sunlog` logs every sun write from any widget with a traceback, for finding out what reset a sun. +- SURFACE and LAYERS gained an INFLUENCE section (requested by PtaQ and MrBob): an altitude band and a slope band, each with a feather, that scale a stroke instead of cutting it the way the FILTERS do, so a texture does more of its thing in the lowlands or on the flats and fades out beyond them. SURFACE remembers a profile per texture (it follows the texture across slots and biome swaps, projects keep it in surface.lua, and Copy to all stamps it onto every slot); LAYERS keeps one per channel. Erasing is never scaled, and the Ctrl sneak peek shows the band so what you see is what lands. + +- SURFACE > FILL AND SEED gained an AUTOMATIC DEPOSIT block (requested by PtaQ and MrBob, for Teizer's craters): a SURFACE variant slot the shader claims on its own where wind-blown sand would gather, on the lee side of slopes relative to a wind direction and in the pockets the intermediary already reads. It only fills ground no stroke claimed, so paint wins; cliffs stay clean; and it is off until a slot is chosen. It is an authoring control, so it sits with the other fill tools rather than in the tileset config; the values still save with the tileset knobs. +- SURFACE > GRADING gained SELECTED SLOT tints: a per-texture albedo tint for the armed variant, remembered by texture like FLIP, saved with the project in tileset.lua, and independent of the TOPS group tint that moves every top together. (The Teizer tileset briefly borrowed two grass tops and tinted a desert top green as an oasis stand-in; both were walked back the same day, the oasis is an asset-side job.) +- The Tileset window's METAL SPOTS glow light gained the LIGHTS tool's point-light controls (requested by PtaQ): INTENSITY, RADIUS and HEIGHT sliders, the colour swatch palette with a preview bar and RED / GREEN / BLUE sliders. The block grays out while the glow is off. The settings are tileset knobs, so they save with the project, ride in presets and come back on a section RESET; picking a style reseeds the colour, intensity and radius from the style, and the on/off state now persists too. +- METAL SPOTS gained a BAND WIDTH slider (requested by PtaQ): how far the silhouette band, the rubble skirt that Band layer and Band tone shape, reaches around a spot. 1 is the previous look; below it the skirt tightens, above it the band spreads to roughly four times the old reach. It saves with the tileset knobs and a style pick or section RESET restores it. +- The Tileset window gained a WORLDSPACE TINT (WIP) section (requested by PtaQ; the GRADIENT STOPS by MrBob): world-height tinting as one system with a wide look range, from the old three-colour grade to a layered canyon. One shared AXIS feeds every lever: Auto rides the map's height reference, so a flat canvas stays untinted, and Manual pins it to elmos and can be tilted along a direction and wobbled. GRADE is the old three stops as colour chips with a movable split. STRATA lays repeating tint beds along the axis: one to eight beds, their period, phase, hardness (a soft crossfade to a knife edge), wobble and per-bed jitter, with BASE / INTER / CLIFF / PLAT chips choosing which layers take them and a colour chip per bed. GRADIENT STOPS is MrBob's array of two to eight hue / saturation / value entries spread evenly over the height, in Multiply or Colorize mode. RAMP samples a painted gradient image along the axis (a PNG from Terraform Brush/Ramps/, picked from a file list with Rescan folder and Clear) in Multiply or Tint mode, with a strength and a repeat count. SATURATION scales colour from base to top, TIDE RINGS darkens rings above the waterline, and SNOW LINE whitens the tops above a height with a band, a slope limit and its own colour. Every colour chip (grade stops, beds, gradient stops, snow) selects what one shared COLOUR editor edits: the swatch palette, a preview bar, and Red / Green / Blue plus Hue / Sat / Value sliders that work for every chip whichever way it is stored. Each sub-group has its own RESET and the header RESET takes them all; the chips follow presets, project loads and console changes. The ramp file is saved with the project next to the biome. Needs tileset shader 0.27; with the shipped defaults a map renders exactly as it did. It is marked WIP: it will get more work in a later release. + +- SHAPE gained a FOLLOW STROKE chip (requested by MrBob and PtaQ): the brush shape turns to the direction you are dragging, so a square, hexagon or triangle leaves a ribbon with its flat sides along the path instead of a chain of same-angle stamps. The angle is smoothed over the last few dabs, so mouse jitter cannot spin the shape, and quantised to 2 degrees, the step the brush's own falloff cache keys on. The protractor snaps it to the spoke grid when both are on, the brush ring shows the angle that will land, and your own rotation comes back when the stroke ends. The chip is offered for the sculpt modes only (raise, lower, level, smooth, smudge): the other tools sharing the SHAPE row stamp rather than stroke. +- A CLAY SCULPT preset ships MrBob's sculpting setup from the campaign terrain tutorial: clay on, intensity at the floor, the sharpest falloff, square with FOLLOW STROKE. It is the single brush he makes a whole map with. +- Every DISPLAY row gained a PASSABILITY chip: it tints ground steeper than a move class in the engine's impassable purple, so cliff height can be judged while sculpting without selecting a unit and pressing F6. Clicking cycles BOT, VEH, HOVER, AMPH and off; the slopes come off the real movedefs, so the band matches what F6 draws. Needs the tileset shader. +- Every DISPLAY row also gained an IMAGE chip (requested by PtaQ): lay a reference image over the whole map, a real coastline to sculpt against or a transparent sheet of guide lines, and paint over it. Images are read from Terraform Brush/Overlays/ in the install folder (PNG, JPG, TGA, BMP or DDS); alpha is kept, so only the lines of a transparent sheet show. The gear next to the chip (or a right click on it) opens the IMAGE OVERLAY window with the file list and the placement controls: OPACITY, OFFSET X / Y and SCALE sliders that preview live on the ground while you drag, STRETCH or KEEP ASPECT, FLIP H / V and a RESET; a left click on the chip toggles the overlay once an image is loaded. The image is projected onto the ground through the map's depth, so it hugs every slope at any map size for one fullscreen pass, and units and features draw over it. The pick and placement survive a reload. `/tfimage ` picks a file from the console, `/tfimage` alone toggles it, `/tfimage off` unloads it. +- SURFACE gained SCATTER: position, size and strength jitter per stamp, on top of SPACING. With spacing near one and a half brush widths, one drag lays the hand-placed dot field the texturing pass wants instead of a solid band. The DOT preset now carries a generous falloff and the scatter, matching MrBob's texturing brush. +- The SURFACE palette captions each texture with its role (TINT, NORMAL and so on). A tileset manifest can name a role per texture; without one the tutorial's numbering convention is used, where 002 is the tint accent and 003 the sculpted-normal style layer. +- The header gained FOCUS MODE, an eye button next to the pause button (requested by PtaQ): it hides the game interface the way F5 does but leaves the editor alive. The panel, the brush preview, the grid and water overlays and skybox switching all keep working, so a map can be looked at, sculpted and screenshotted without the HUD. Skybox picks used to wait for the HUD to come back, because they were applied from the one draw call-in the interface hide skips; they now apply from DrawScreenPost, as do the New Map reload's environment preset, fog-off and forcestart, the heightmap import and export jobs, and the project Save / Open driver, none of which would have run under a hidden interface. Closing the panel, quitting the editor, a `/luaui reload` or F5 all end focus mode and hand the interface back; hiding the panel with its hotkey does not, so focus plus a hidden panel is the clean-screenshot setup. A map capture started in focus mode still comes out clean and leaves the interface hidden afterwards. +- A New Map and an opened project bring the Terraformer up by themselves (requested by PtaQ): the brush arms in RAISE the moment the canvas is playable, at the New Map's forcestart and at the end of a project's load phases, unless a tool is already up. + +### Improvements + +- Sculpt drags cost a fraction of what they did (reported by huskyvoiceyui and Moose on big maps). The gadget now applies every dab of a tick against a working copy of the cells it touches and commits once per tick, so the engine's terrain recalculation (mip heightmaps, normals, slopes, pathing, LOS, the normal and shading textures, mesh patch updates) runs once per tick per symmetry copy instead of once per dab (up to 48 times), the undo stack gets one entry per tick instead of one per dab (a stroke's undo is that much cheaper too), and the per-cell engine reads drop to one per tick. Non-clay results are unchanged to floating-point noise. +- The falloff-stamp cache no longer keys the circle and ring on rotation (FOLLOW STROKE with the default circle was rebuilding an identical stamp every 2 degrees of tangent), holds stamps by a cell budget instead of a fixed four, and steps very large shaped stamps at a coarser angle so a FOLLOW drag at big radius cannot rebuild a 100k-cell stamp per dab. +- The ground mesh refresh is now armed from the engine's own heightmap-update event instead of ten frames per brush tick, which had the whole visible mesh re-tessellating every frame for the length of a drag (the bigger the map, the more it cost). The panel mirrors terraform state at a quarter rate while the brush is down on the world, and its per-frame slider writes go through the dirty-check helper. +- Settings > General gained a persisted Performance mode toggle (requested by PtaQ for the mapmaking artists): wider dab spacing where the falloff can take it (a quarter radius for soft curves and clay, a fifth up to curve 2.0, unchanged above), 32 dabs per tick instead of 48, 6-degree FOLLOW STROKE steps, and the panel readouts strided all the time. Its description names the two free levers, pausing the game and Focus mode. +- Sculpt strokes follow the path the mouse actually drew. The brush sampled the cursor 20 times a second and bridged a straight line between samples, so a fast scribble arrived with its corners cut and its dabs delivered in visible bursts. Every mouse move is now recorded and the tick walks that polyline, dropping dabs at the brush's own spacing. +- Clay strokes deposit per distance travelled, not per tick. A tick's intensity used to be divided by the number of dabs it contained, so the faster you moved the less clay landed, to the point where a fast pass left almost nothing. A whole tick of dabs is now sent as one message and the gadget derives every clay plane from the heightmap as it was before the tick, so speed no longer removes the stroke and overlapping dabs still cannot compound past one brush step. It also cuts up to 48 network messages a tick down to one. +- SURFACE brush size steps by a tenth of the current size per scroll notch instead of a flat 8 elmos, so a small brush can be tuned (reported by MrBob). +- The New Map wizard's ENVIRONMENT pick opens on Default again, and Default now means the canonical editor sun (requested by PtaQ). It used to mean "leave the engine lighting alone", which is placeholder lighting - ground ambient and diffuse both a flat 0.5 against about 0.99 on a real daylight map - so the wizard had been pointed at a harvested mood to work around it, and every new map inherited that map's water, fog and sky with it. Default now applies the sun and nothing else; the harvested moods are still in the list. +- SURFACE altitude bounds gained SAMPLE buttons like the raise tool's HEIGHT CAP (requested by PtaQ): the FILTERS Alt min / Alt max rows in both modes and the INFLUENCE Alt min / Alt max rows arm the height sampler, the next click on the terrain (or on a colormap contour) writes that height into the bound, pushes the other bound along if the band would invert, and switches the filter or band on. +- Sun & Shadows PRESETS trimmed to three (requested by PtaQ): Canonical, Dusk and Overcast. Canonical is PtaQ's saved editor sun and is also the New Map wizard's Clear Daylight sun. +- Project folders and names may contain spaces (requested by PtaQ): a cloned maps repository with spaces in its folder names now lists in Open Project, and Save As accepts them; a name still cannot start or end with a space. +- Open Project rows are set larger and the window is wider, so project names read at a glance (requested by PtaQ and MrBob). +- The sun sliders keep the applied intensity on every nudge (the engine defaults a missing intensity to 1.0), re-assert the ground and unit shadow densities separately instead of flattening them to the ground value, and restamp each other so the vector rows and the azimuth/elevation rows never disagree. + +### Fixes + +- Clay strokes no longer leave concentric rings (reported by huskyvoiceyui and Moose). The clay plane is measured on the surface the stroke started on (the pre-stroke heights, averaged over the dab centre and four taps half a radius out), so every dab of a stroke agrees on one plane and the stroke lays exactly one layer of INTENSITY x 8 elmos. The old plane re-measured the live centre height, which from the second tick on was the disc the previous tick had just raised, so every tick stacked another disc one layer up and the disc edges showed as rings (a 40-tick slow drag at intensity 10 piled 2700 elmos). The stacking survives as Settings > Stroke > Clay build-up for anyone who wants a held brush to keep piling. +- The SURFACE Ctrl sneak peek works at every zoom again (reported by PtaQ). It only exists in the shader's live stack, and last release's far cache and clipmap take over the ground as soon as the camera pulls back, so the preview quietly stopped past a close zoom. A pixel under the brush now takes the live path whatever the distance, and the cache-only shader program stands down while a peek is up. +- DISPLAY chip rows wrap onto a second line instead of squeezing every label onto two lines when a tool has five chips (reported by PtaQ). +- Text fields that could not be typed into now can (reported by Moose): the Open Project filter, the Light Library filter and its preset name box, the grass blade and colour-mod texture paths, and the Lights orientation pitch/yaw boxes. A field only receives the keyboard if it starts SDL text input when focused, and these five were added without it, so the game ate every keystroke. Every text field in the panel now goes through one helper instead of each copying the same eight lines. +- The sun config survives a project open and a preset (requested by PtaQ and MrBob). Three paths could still lose it: the ENV panel's RESET buttons returned to the lighting the engine held when the panel first opened, which on a canvas is the flat blank-map default, and are now re-anchored after every project or preset apply; a preset without an intensity (the map moods have none) forced it back to 1.0 and now keeps the session's; and a skybox fade in flight restored the sun colours it had captured over a config applied mid-fade, and is now retargeted at the new colours. +- Editor sessions no longer spawn commanders (requested by PtaQ and MrBob). New Map and Open Project start scripts carry an `editor_sandbox` flag; the initial-spawn gadget skips the commander for those sessions and the game-end gadgets stand down on the same flag. A project load runs in pregame, so it used to wipe the units before any commander existed and then start the game, which spawned one per team at a guessed spot; New Map spawned one the moment the session started. Editor canvases now also leave pregame on their own a few frames after boot, so terrain raised above the canvas base is clickable without a manual start. + ## 1.13 - 2026-09-02 ### New diff --git a/doc/TerraformBrush.md b/doc/TerraformBrush.md index f3ccab6b888..43f03c003a2 100644 --- a/doc/TerraformBrush.md +++ b/doc/TerraformBrush.md @@ -165,7 +165,11 @@ When the **Height Colormap** overlay is active, each cap row shows a **SAMPLE** ### Clay Mode -"Flat buildup" — creates plateau-like terrain with a flat top at the brush's target height rather than the standard dome falloff. Sent as a flag (`0`/`1`) in the terraform message. Toggle with `X`. +"Flat buildup" — creates plateau-like terrain with a flat top at the brush's target height rather than the standard dome falloff. Toggle with `X`. + +Each dab targets a **plane** at the stroke's reference height plus `INTENSITY × 8` elmos (raise) or minus it (lower); cells on the wrong side of the plane blend toward it by `falloff × opacity × intensity` per dab and never cross it. The reference is the **pre-stroke surface**: the heights every cell had when the stroke started, averaged over the dab centre and four taps half a radius out. A stroke therefore lays exactly one layer over the ground it started on, however slowly you drag or however much the dabs overlap, and the layer's edge follows the falloff. Measuring the plane on the live centre height instead stacked a new disc every tick, and the disc edges came out as concentric rings; that behaviour survives as **Settings > Stroke > Clay build-up** for anyone who wants a held brush to keep piling layers. + +Sent as the clay flag in the terraform messages: `0` off, `1` clay, `2` clay with build-up. Clay mode applies to **all terrain modes** (raise, lower, level, smooth, ramp, restore, noise) and **all shapes** (circle, square, triangle, hexagon, octagon, ring). In ramp mode the flattened profile applies along the full ramp length. @@ -673,6 +677,23 @@ Mode buttons (raise/lower/level/smooth/ramp/restore/noise) · Shape buttons · P | Dust effects | off | CEG particle bursts + rumble sounds on each op (DJ Mode) | | Velocity intensity | off | Scale brush intensity by mouse drag speed | +### Performance Mode + +**Settings > General > Performance mode** (persisted in `ui_prefs.lua`). For big maps and slower machines; the tools stay the same, sculpting just samples more economically: + +| Lever | Default | Performance mode | +|-------|---------|------------------| +| Dab spacing along the stroke | 15 % of radius | 24 % for soft curves (≤ 1.0) and clay, 20 % up to curve 2.0, 15 % above | +| Dabs per 20 Hz tick (cap) | 48 | 32 | +| FOLLOW STROKE angle step | 2° | 6° (a third of the stamp builds on shaped brushes) | +| Panel terraform mirror | every frame (every 4th frame while dragging) | every 4th frame; frame rate again while the mouse is over the panel | + +The spacing rule is falloff-aware: a soft dome sums smoothly at a quarter radius and a clay stroke converges on one plane whatever the spacing, while hard-edged curves keep the full density so they do not band. + +Two free levers regardless of the toggle: pausing the game while sculpting spares the pathfinder's terrain updates, and Focus mode (the eye icon in the header) drops the rest of the HUD. + +Always on, no toggle needed: the gadget commits a tick's dabs in one heightmap write and one undo entry (see Undo / Redo System), the falloff-stamp cache is rotation-invariant for circles and rings and budgeted by cells, and the ground mesh refresh is armed by the engine's heightmap-update event rather than per brush tick. + ### Presets Built-in presets (non-deletable) and unlimited user presets. Stored in `LuaUI/Config/TerraformPresets/*.lua`. @@ -717,6 +738,7 @@ All terrain edits go through `SendLuaRulesMsg()` to the server-side gadget. | Message | Format | |---------|--------| | `$terraform_brush$` | `dir x z radius shape rot curve capMin capMax intensity lengthScale clay dust opacity instant flattenHeight [ringInnerRatio]` | +| `$terraform_stroke$` | `dir radius shape curve capMin capMax intensity lengthScale clay dust opacity instant flattenHeight ringInnerRatio nDabs x1 z1 rot1 [x2 z2 rot2 ...]` — one per tick per symmetry copy, every dab of the tick; applied as one batch (one heightmap commit, one undo entry) | | `$terraform_ramp$` | `startX startZ startY endX endZ endY radius clay dust` | | `$terraform_ramp_spline$` | `radius pointCount [x1 z1 x2 z2 ...] clay dust` | | `$terraform_restore$` | `x z radius shape rot curve intensity lengthScale` | @@ -724,8 +746,8 @@ All terrain edits go through `SendLuaRulesMsg()` to the server-side gadget. | `$terraform_import$` | `columnX height1 height2 ...` | | `$terraform_undo$` | (no args) | | `$terraform_redo$` | (no args) | -| `$terraform_merge_end$` | (no args) — sent by widget on mouse release to finalize the drag-stroke undo entry | -| `$terraform_stroke_end$` | (no args) — marks the end of a distinct stroke for diagnostics | +| `$terraform_merge_end$` | (no args) — sent by the widget after every brush tick; closes the tick's undo entry | +| `$terraform_stroke_end$` | (no args) — sent on mouse release; advances the stroke id (`$terraform_undo_stroke$` pops all entries of the latest id) and drops the pre-stroke heights the clay plane measures against | **Feature placer messages** (`luarules/gadgets/cmd_feature_placer.lua`). Every mutating branch is gated on `Spring.IsCheatingEnabled()`. @@ -793,7 +815,7 @@ gizmo-transformed anyway. | 8–9 | `capMin capMax` | float or empty | Height cap bounds | | 10 | `intensity` | float | 0.1–100 | | 11 | `lengthScale` | float | 0.2–5.0 | -| 12 | `clay` | 0/1 | Clay mode | +| 12 | `clay` | 0/1/2 | Clay mode (`2` = with per-tick build-up) | | 13 | `dust` | 0/1 | Dust/DJ mode | | 14 | `opacity` | float | 0.01–1.0 | | 15 | `instant` | 0/1 | Stamp mode | @@ -858,25 +880,23 @@ After each terraform op, `tessellationDirtyFrames` is set to 10. Counter decreme History is maintained as a **server-side stack** in the gadget. All terrain modifications snapshot the previous state before applying. -#### Stroke Merge (Drag → Single Undo Entry) +#### Stroke Entries (One Per Tick) -Each brush stroke fires many `$terraform_brush$` messages per second while the mouse is held. Rather than creating hundreds of separate undo entries, all changes during a single drag are merged into **one entry**: +Each brush tick sends one `$terraform_stroke$` message per symmetry copy carrying every dab of that tick. The gadget applies the dabs in order against a working copy of the cells they touch (read from the engine once, on first touch) and commits **once per message**: one `SetHeightMapFunc` (so one engine terrain recalculation) and **one undo entry** built straight from the pre-tick heights of the cells it wrote. Dab k still sees dab k-1's writes, so the result is what sequential commits produced, at a fraction of the engine work. -- On each push during an active drag, new vertices are added to the current snapshot — duplicates (same x/z already snapshotted) are skipped via a numeric hash set, so re-visiting a cell doesn't grow the snapshot. -- When the mouse is released the widget sends **`$terraform_merge_end$`**, which finalizes the snapshot and closes the merge window. -- Undo/redo each restore the entire drag stroke in a single step. +Entries of one drag share a stroke id: `$terraform_merge_end$` closes the tick, `$terraform_stroke_end$` (mouse release) advances the id, and `$terraform_undo_stroke$` pops every entry with the latest id in one step. Cross-tick merging is deliberately not done (it produced striped leftovers on undo). -Ramp and spline operations always produce a new independent entry (no merge). +Ramp and spline operations always produce a new independent entry. #### Storage Format -Snapshots are stored as **flat arrays** `{x, z, h, x, z, h, ...}` instead of sub-tables `{{x,z,h},...}`. This eliminates the tens-of-thousands of per-vertex sub-table allocations that caused `SetHeightMapFunc` heavy operations to spike GC. +Snapshots are stored as a **bbox grid**: a mask and a height grid over the entry's bounding box (`minX`, `minZ`, `w`, `h`, `ss`). Cells still at their map-original height store a mask bit only (`2`) and no height; edited cells store `1` plus the pre-edit height. Brush ticks build the grid directly from their working copy; the ramp, noise, erode and fill ops convert a flat `{x, z, h, ...}` buffer, which itself replaced per-vertex sub-tables that used to spike GC. #### Vertex Budget (Anti-OOM) | Constant | Value | Meaning | |----------|-------|---------| -| `MAX_UNDO` | 2000 | Maximum entries in undo or redo stack | +| `MAX_UNDO` | 10000 | Maximum entries in undo or redo stack | | `MAX_SNAPSHOT_VERTICES` | 8 000 000 | ~192 MB — total vertex budget across all stacked snapshots | When `totalVertexCount` exceeds the budget, the **oldest** undo entries are evicted until under budget. If still over, the oldest redo entries are also evicted. This prevents OOM crashes with very large-radius restore/noise operations on wide maps. diff --git a/luarules/gadgets/cmd_terraform_brush.lua b/luarules/gadgets/cmd_terraform_brush.lua index a1937bf9adb..026601ab096 100644 --- a/luarules/gadgets/cmd_terraform_brush.lua +++ b/luarules/gadgets/cmd_terraform_brush.lua @@ -444,16 +444,14 @@ local function finalizeMerge() mergeSnapshotLen = 0 end --- Hot-path: convert a flat {x,z,h,...} buffer to a bbox-grid snapshot and push. --- ONE ENTRY PER TICK is mandatory (see bar_stripy_terrain_bug.md). All snapshot --- callers route through this; pushSnapshot below flattens sub-tables first. -local function pushSnapshotFromFlat(flatBuf, vertexCount) - if vertexCount == 0 then - return - end - if vertexCount > MAX_SNAPSHOT_VERTICES then - return - end +-- Convert a flat {x,z,h,...} buffer to a bbox-grid snapshot and push it. +-- ONE ENTRY PER TICK is mandatory (see bar_stripy_terrain_bug.md): brush dabs +-- commit through flushBatch (one entry per STROKE message); the ramp, noise, +-- erode and fill ops route through here; pushSnapshot flattens sub-tables first. +-- Push a ready bbox-grid snapshot as a new undo entry. Bookkeeping shared by +-- the flat converter below and the per-tick batch commit (flushBatch). +local function pushBboxSnapshot(snapshot) + local vertexCount = snapshot.vertexCount or 0 finalizeMerge() for i = 1, #redoStack do @@ -461,7 +459,6 @@ local function pushSnapshotFromFlat(flatBuf, vertexCount) end redoStack = {} - local snapshot = flatToBboxSnapshot(flatBuf, vertexCount) snapshot.strokeId = currentStrokeId undoStack[#undoStack + 1] = snapshot totalVertexCount = totalVertexCount + vertexCount @@ -478,6 +475,16 @@ local function pushSnapshotFromFlat(flatBuf, vertexCount) SendToUnsynced("TerraformBrushStacks", #undoStack, #redoStack) end +local function pushSnapshotFromFlat(flatBuf, vertexCount) + if vertexCount == 0 then + return + end + if vertexCount > MAX_SNAPSHOT_VERTICES then + return + end + pushBboxSnapshot(flatToBboxSnapshot(flatBuf, vertexCount)) +end + -- Sub-table format {{x,z,h},...} cold path: flatten via scratchSnapFlat then -- route through pushSnapshotFromFlat. Currently unused but kept for API stability. local function pushSnapshot(snapshot) @@ -827,26 +834,57 @@ end -- lengthScale → 0.05 step -- ringRatio → 0.02 step (only matters for "ring" shape) -- --- LRU eviction: keep at most FALLOFF_STAMP_LIMIT stamps. A radius-2000 stamp --- is ~400 k floats ≈ 16 MB; 4 such = 64 MB max worst-case. -local FALLOFF_STAMP_LIMIT = 4 +-- LRU eviction by cell budget: a stamp holds w*h table slots (a radius-2000 +-- stamp is ~500 k of them). Up to FALLOFF_STAMP_CELL_BUDGET slots stay +-- resident, so a FOLLOW STROKE drag with a small or mid brush keeps every +-- angle of its rotation cached instead of thrashing the old fixed 4-entry +-- list and rebuilding one O(w*h) stamp (sin/cos/pow per cell) per dab. +local FALLOFF_STAMP_CELL_BUDGET = 3000000 local FALLOFF_EPSILON = 1 / 255 -- below this, treat as zero (sub-quantisation) local falloffStampCache = {} local falloffStampGen = {} -- key → last-use generation (monotonic clock) -local falloffStampCount = 0 +local falloffStampSize = {} -- key → w*h, for the budget accounting +---@type number +local falloffStampCells = 0 -- data slots held by every cached stamp together local falloffStampClock = 0 -local function quantiseStampParams(radius, angleDeg, curve, lengthScale, ringRatio) +-- Cells one stamp of this radius / length scale occupies at grid step ss +-- (its bounding window; mirrors buildFalloffStamp's extent maths). +local function stampCellCount(radius, lengthScale, ss) + local halfCells = floor(radius * max(1, lengthScale) * 1.42 / ss) + local size = halfCells * 2 + 1 + return size * size +end + +local function quantiseStampParams(radius, shape, angleDeg, curve, lengthScale, ringRatio, ss) local rQ = floor(radius) - -- Wrap angle to [0,360) before quantising so 359° and -1° share a stamp. - local aN = angleDeg % 360 - local aQ = floor(aN / 2 + 0.5) * 2 - if aQ >= 360 then - aQ = aQ - 360 - end local cQ = floor(curve / 0.05 + 0.5) * 0.05 local lQ = floor(lengthScale / 0.05 + 0.5) * 0.05 local rrQ = floor(ringRatio / 0.02 + 0.5) * 0.02 + local aQ + if (shape == "circle" or shape == "ring") and lQ == 1 then + -- Rotation-invariant footprint: one stamp serves every angle. FOLLOW + -- STROKE with the default circle used to rebuild an identical stamp + -- every 2 degrees of tangent. + aQ = 0 + else + -- 2 deg steps while a full rotation of stamps fits the cache with room + -- to spare; coarser for huge footprints so a FOLLOW drag cannot rebuild + -- a 100 k-cell stamp per dab (capped at 30 deg). The widget quantises + -- its tangent to 2 deg too, so previews and stamps agree for anything + -- but the biggest brushes. + local aStep = 2 + local cells = stampCellCount(rQ, lQ, ss) + if cells > 40000 then + aStep = min(30, 2 * math.ceil(cells / 40000)) + end + -- Wrap angle to [0,360) before quantising so 359° and -1° share a stamp. + local aN = angleDeg % 360 + aQ = floor(aN / aStep + 0.5) * aStep + if aQ >= 360 then + aQ = aQ - 360 + end + end return rQ, aQ, cQ, lQ, rrQ end @@ -876,7 +914,7 @@ local function buildFalloffStamp(radius, shape, angleDeg, curve, lengthScale, ri end local function getFalloffStamp(radius, shape, angleDeg, curve, lengthScale, ringRatio, ss) - local rQ, aQ, cQ, lQ, rrQ = quantiseStampParams(radius, angleDeg, curve, lengthScale, ringRatio) + local rQ, aQ, cQ, lQ, rrQ = quantiseStampParams(radius, shape, angleDeg, curve, lengthScale, ringRatio, ss) local key = string.format("%s|%d|%d|%.2f|%.2f|%.2f|%d", shape, rQ, aQ, cQ, lQ, rrQ, ss) falloffStampClock = falloffStampClock + 1 local stamp = falloffStampCache[key] @@ -884,24 +922,225 @@ local function getFalloffStamp(radius, shape, angleDeg, curve, lengthScale, ring falloffStampGen[key] = falloffStampClock return stamp end - stamp = buildFalloffStamp(rQ, shape, aQ, cQ, lQ, rrQ, ss) - falloffStampCache[key] = stamp + local built = buildFalloffStamp(rQ, shape, aQ, cQ, lQ, rrQ, ss) + falloffStampCache[key] = built falloffStampGen[key] = falloffStampClock - falloffStampCount = falloffStampCount + 1 - if falloffStampCount > FALLOFF_STAMP_LIMIT then + local builtCells = built.w * built.h + falloffStampSize[key] = builtCells + falloffStampCells = falloffStampCells + builtCells + -- Evict least-recently-used stamps until the budget holds; the one just + -- built stays whatever its size. + while falloffStampCells > FALLOFF_STAMP_CELL_BUDGET do local oldKey, oldGen for k, g in pairs(falloffStampGen) do - if oldGen == nil or g < oldGen then + if k ~= key and (oldGen == nil or g < oldGen) then oldKey, oldGen = k, g end end + if not oldKey then + break + end + falloffStampCells = falloffStampCells - (falloffStampSize[oldKey] or 0) falloffStampCache[oldKey] = nil falloffStampGen[oldKey] = nil - falloffStampCount = falloffStampCount - 1 + falloffStampSize[oldKey] = nil + end + return built +end + +-- ─── PER-TICK BATCH ────────────────────────────────────────────────────────── +-- A STROKE message carries every dab of a widget tick (up to 48). They used to +-- commit one at a time: one SetHeightMapFunc per dab, so one engine RecalcArea +-- (mip heightmaps, face/vertex normals, slopes, pathing, LOS, the unsynced +-- normal + shading textures, ROAM patch dirtying) per dab, plus one undo entry +-- per dab, with a GetGroundHeight + GetGroundOrigHeight + SetHeightMap engine +-- call per cell per dab -- on footprints that overlap ~85 % at the 15 %-of- +-- radius dab spacing. That was the sculpt-drag frame cost artists reported. +-- +-- Now the dabs of one message apply in order against a working copy of the +-- cells they touch (read from the engine once, on first touch), and the tick +-- commits once: one SetHeightMapFunc over the touched cells and one undo entry +-- built straight from the pre-tick copy. Dab k still sees dab k-1's writes, +-- so the result is exactly what the sequential commits produced. +-- +-- Cells are keyed by a map-global index (zCell * batchCols + xCell + 1); the +-- tables are sparse and reused, cleared by walking the touch lists. +local SQUARE_SIZE = Game.squareSize +local batchCols = floor(Game.mapSizeX / SQUARE_SIZE) + 1 +---@type table +local batchNew = {} -- cellIdx -> height written this tick (working copy) +---@type table +local batchPre = {} -- cellIdx -> height read from the engine at first touch +---@type number[] +local batchWriteList = {} -- cellIdx per written cell, first-write order +local batchWriteN = 0 +---@type number[] +local batchReadList = {} -- cellIdx per cell fetched from the engine +local batchReadN = 0 +local batchOpen = false +-- Undo bbox of the tick, in cells; reset to +-huge by beginBatch. +local batchMinXc, batchMinZc, batchMaxXc, batchMaxZc = math.huge, math.huge, -math.huge, -math.huge + +-- Pre-stroke heights: what every cell measured before the current stroke +-- first wrote it, kept until STROKE_END. Clay planes are taken against these +-- so a stroke lays ONE layer over the surface it started on. The old plane +-- re-measured the live centre height, i.e. the disc the previous tick had +-- just raised, and every tick stacked another disc one layer up: that is +-- where the concentric rings on every clay stroke came from. +---@type table +local strokeOrig = {} +---@type number[] +local strokeOrigList = {} +local strokeOrigN = 0 +local STROKE_ORIG_LIMIT = 4000000 + +local function clearStrokeOrigin() + for i = 1, strokeOrigN do + strokeOrig[strokeOrigList[i]] = nil + end + strokeOrigN = 0 +end + +local function beginBatch() + batchOpen = true + batchWriteN = 0 + batchReadN = 0 + batchMinXc, batchMinZc = math.huge, math.huge + batchMaxXc, batchMaxZc = -math.huge, -math.huge + -- A stroke that never got its STROKE_END (widget reload mid-drag) must not + -- pin the whole map's pre-stroke heights forever. + if strokeOrigN > STROKE_ORIG_LIMIT then + clearStrokeOrigin() + end +end + +-- Height of a cell as this tick currently sees it: this tick's write if any, +-- else the engine value, cached in batchPre on first read. Callers clamp the +-- cell into the map. +local function batchRead(xCell, zCell) + local idx = zCell * batchCols + xCell + 1 + local v = batchNew[idx] + if v ~= nil then + return v + end + v = batchPre[idx] + if v == nil then + v = GetGroundHeight(xCell * SQUARE_SIZE, zCell * SQUARE_SIZE) + batchPre[idx] = v + batchReadN = batchReadN + 1 + batchReadList[batchReadN] = idx + end + return v +end + +-- SetHeightMapFunc wants a function argument; this one walks the write list. +local function batchCommitWorker() + for i = 1, batchWriteN do + local idx = batchWriteList[i] or 0 + local h = batchNew[idx] + if h == h then -- NaN check: NaN ~= NaN + local zc = floor((idx - 1) / batchCols) + SetHeightMap(((idx - 1) - zc * batchCols) * SQUARE_SIZE, zc * SQUARE_SIZE, h) + else + nanHeightSkipped = true + end end - return stamp end +-- Commit the tick: one heightmap write, one undo entry, then reset. +local function flushBatch() + batchOpen = false + if batchWriteN > 0 then + SetHeightMapFunc(batchCommitWorker) + if nanHeightSkipped then + Spring.Echo("[Terraform Brush] Warning: NaN height skipped — possible div0 in brush math") + nanHeightSkipped = false + end + -- Undo entry straight from the pre-tick heights (bbox-grid format, see + -- flatToBboxSnapshot): no flat intermediate, and one GetGroundOrigHeight + -- per touched cell per tick rather than per dab. + if batchWriteN <= MAX_SNAPSHOT_VERTICES then + local w = batchMaxXc - batchMinXc + 1 + local h = batchMaxZc - batchMinZc + 1 + local mask, hgrid = {}, {} + for i = 1, batchWriteN do + local idx = batchWriteList[i] or 0 + local zc = floor((idx - 1) / batchCols) + local xc = (idx - 1) - zc * batchCols + local sIdx = (zc - batchMinZc) * w + (xc - batchMinXc) + 1 + local pre = batchPre[idx] + if pre == GetGroundOrigHeight(xc * SQUARE_SIZE, zc * SQUARE_SIZE) then + mask[sIdx] = 2 + else + mask[sIdx] = 1 + hgrid[sIdx] = pre + end + end + pushBboxSnapshot({ + format = "bbox", + minX = batchMinXc * SQUARE_SIZE, + minZ = batchMinZc * SQUARE_SIZE, + w = w, + h = h, + ss = SQUARE_SIZE, + mask = mask, + hgrid = hgrid, + vertexCount = batchWriteN, + }) + end + for i = 1, batchWriteN do + batchNew[batchWriteList[i]] = nil + end + end + for i = 1, batchReadN do + batchPre[batchReadList[i]] = nil + end + batchWriteN = 0 + batchReadN = 0 +end + +-- Clay target plane for a dab. Measured on the pre-stroke surface (strokeOrig +-- where this stroke already wrote, the live ground elsewhere) as the mean of +-- the centre and four taps half a radius out, so the dabs of one stroke agree +-- on a plane instead of each re-measuring the disc the previous one left. +-- stack=true is the legacy per-tick build-up: the plane sits on the live +-- centre height, so a held or slow drag keeps piling layers (and rings). +local function clayPlaneFor(centerX, centerZ, radius, rise, stack) + if stack then + return GetGroundHeight(centerX, centerZ) + rise + end + local maxXc = floor(Game.mapSizeX / SQUARE_SIZE) + local maxZc = floor(Game.mapSizeZ / SQUARE_SIZE) + local cxc = floor(centerX / SQUARE_SIZE + 0.5) + local czc = floor(centerZ / SQUARE_SIZE + 0.5) + local r = max(1, floor(radius * 0.5 / SQUARE_SIZE)) + local sum = 0.0 + for t = 1, 5 do + local xc, zc = cxc, czc + if t == 2 then + xc = cxc - r + elseif t == 3 then + xc = cxc + r + elseif t == 4 then + zc = czc - r + elseif t == 5 then + zc = czc + r + end + xc = max(0, min(maxXc, xc)) + zc = max(0, min(maxZc, zc)) + local h = strokeOrig[zc * batchCols + xc + 1] + if h == nil then + h = GetGroundHeight(xc * SQUARE_SIZE, zc * SQUARE_SIZE) + end + sum = sum + h + end + return sum / 5 + rise +end + +-- clayMode: false/nil off, 1 = clay (one layer per stroke), 2 = clay with +-- per-tick build-up (legacy). Dabs inside a STROKE batch get clayPlaneIn from +-- handleStroke; a dab on its own (per-dab BRUSH message, sticky replay) opens +-- and commits a batch of one. local function applyTerraform( centerX, centerZ, @@ -920,28 +1159,27 @@ local function applyTerraform( instant, localBlur, localSmudge, - smudgeStart + smudgeStart, + clayPlaneIn ) - local squareSize = Game.squareSize - local mapSizeX = Game.mapSizeX - local mapSizeZ = Game.mapSizeZ + local squareSize = SQUARE_SIZE + local maxXc = floor(Game.mapSizeX / squareSize) + local maxZc = floor(Game.mapSizeZ / squareSize) lengthScale = lengthScale or 1.0 - -- Clay mode: compute a target plane at center height + full brush displacement - local clayPlane - if clayMode and direction ~= 0 and direction ~= 2 then - local centerHeight = GetGroundHeight(centerX, centerZ) - clayPlane = centerHeight + direction * HEIGHT_STEP * intensity + local standalone = not batchOpen + if standalone then + beginBatch() + end + + -- Clay mode: target plane at the reference height + full brush displacement. + local clayPlane = clayPlaneIn + if not clayPlane and clayMode and direction ~= 0 and direction ~= 2 then + clayPlane = clayPlaneFor(centerX, centerZ, radius, direction * HEIGHT_STEP * intensity, clayMode == 2) end opacity = opacity or 0.3 local dirStep = direction * HEIGHT_STEP - local levelTarget - if direction == 0 and not localBlur and not localSmudge then - -- Heights are only written after the loop, so this matches the - -- per-cell read it replaces. - levelTarget = flattenHeight or GetGroundHeight(centerX, centerZ) - end -- Falloff stamp: precomputed per-cell falloff field keyed by quantised -- (radius, shape, angle, curve, length, ringRatio). Skips per-cell sin/cos @@ -956,6 +1194,15 @@ local function applyTerraform( -- to squareSize/2 ≈ 4 world units; required so the cached stamp aligns). local centerCellX = floor(centerX / squareSize + 0.5) local centerCellZ = floor(centerZ / squareSize + 0.5) + local cols = batchCols + local bNew, bPre = batchNew, batchPre + + local levelTarget + if direction == 0 and not localBlur and not localSmudge then + -- Heights are only written after the loop, so this matches the + -- per-cell read it replaces. + levelTarget = flattenHeight or batchRead(max(0, min(maxXc, centerCellX)), max(0, min(maxZc, centerCellZ))) + end -- Smooth mode (localBlur): each cell blends toward the mean of its OWN -- neighborhood instead of one flat target for the whole stamp, so a cell at @@ -981,12 +1228,20 @@ local function applyTerraform( local padRows = sh + 2 * blurStep for pz = 0, padRows - 1 do local zCell = centerCellZ + (pz - blurStep - sCz) - local bz = max(0, min(mapSizeZ, zCell * squareSize)) + if zCell < 0 then + zCell = 0 + elseif zCell > maxZc then + zCell = maxZc + end local rowBase = pz * blurStride for px = 0, blurStride - 1 do local xCell = centerCellX + (px - blurStep - sCx) - local bx = max(0, min(mapSizeX, xCell * squareSize)) - blurBuf[rowBase + px + 1] = GetGroundHeight(bx, bz) + if xCell < 0 then + xCell = 0 + elseif xCell > maxXc then + xCell = maxXc + end + blurBuf[rowBase + px + 1] = batchRead(xCell, zCell) end end -- SAT[r][c] = sum of blurBuf rows < r, cols < c (zero first row/col). @@ -1076,10 +1331,20 @@ local function applyTerraform( local rate = 0.5 + 0.47 * intensityT for iz = 0, sh - 1 do local rowBase = iz * sw - local bz = max(0, min(mapSizeZ, (centerCellZ + (iz - sCz)) * squareSize)) + local zCell = centerCellZ + (iz - sCz) + if zCell < 0 then + zCell = 0 + elseif zCell > maxZc then + zCell = maxZc + end for ix = 0, sw - 1 do - local bx = max(0, min(mapSizeX, (centerCellX + (ix - sCx)) * squareSize)) - local cur = GetGroundHeight(bx, bz) + local xCell = centerCellX + (ix - sCx) + if xCell < 0 then + xCell = 0 + elseif xCell > maxXc then + xCell = maxXc + end + local cur = batchRead(xCell, zCell) local idx = rowBase + ix + 1 if grab then smudgeHeights[idx] = cur @@ -1089,27 +1354,25 @@ local function applyTerraform( end end if grab then - return -- the first dab of a stroke only grabs; nothing to paint yet + -- the first dab of a stroke only grabs; nothing to paint yet + if standalone then + flushBatch() + end + return end end - -- Reuse scratch tables to reduce per-frame allocation - local heightData = scratchHeightData - local snapFlat = scratchSnapFlat - local hIdx = 0 - local sCount = 0 - for iz = 0, sh - 1 do local sBase = iz * sw local zCell = centerCellZ + (iz - sCz) - local z = zCell * squareSize - if z >= 0 and z <= mapSizeZ then + if zCell >= 0 and zCell <= maxZc then + local rowIdx = zCell * cols + 1 for ix = 0, sw - 1 do local falloff = sdata[sBase + ix + 1] if falloff then local xCell = centerCellX + (ix - sCx) - local x = xCell * squareSize - if x >= 0 and x <= mapSizeX then + if xCell >= 0 and xCell <= maxXc then + local idx = rowIdx + xCell local current local blurTarget if localBlur then @@ -1127,14 +1390,18 @@ local function applyTerraform( + blurSAT[r0Base + c0] ) * blurInvArea else - current = GetGroundHeight(x, z) + -- Inline batchRead: this is the hot path. + current = bNew[idx] + if current == nil then + current = bPre[idx] + if current == nil then + current = GetGroundHeight(xCell * squareSize, zCell * squareSize) + bPre[idx] = current + batchReadN = batchReadN + 1 + batchReadList[batchReadN] = idx + end + end end - -- Write to flat scratch buffer (no sub-table allocation) - local base = sCount * 3 - snapFlat[base + 1] = x - snapFlat[base + 2] = z - snapFlat[base + 3] = current - sCount = sCount + 1 local newHeight @@ -1204,30 +1471,47 @@ local function applyTerraform( end end - hIdx = hIdx + 1 - local he = heightData[hIdx] - if he then - he[1] = x - he[2] = z - he[3] = newHeight - else - heightData[hIdx] = { x, z, newHeight } + -- First write of this cell in the tick: list it, grow the + -- undo bbox, and pin its pre-stroke height for the clay plane. + if bNew[idx] == nil then + batchWriteN = batchWriteN + 1 + batchWriteList[batchWriteN] = idx + if xCell < batchMinXc then + batchMinXc = xCell + end + if xCell > batchMaxXc then + batchMaxXc = xCell + end + if zCell < batchMinZc then + batchMinZc = zCell + end + if zCell > batchMaxZc then + batchMaxZc = zCell + end + -- The blur path read this cell through blurBuf, so it may + -- lack its pre entry: pin it now so the snapshot and the + -- clear loop see it. + local pre = bPre[idx] or current + if bPre[idx] == nil then + bPre[idx] = current + batchReadN = batchReadN + 1 + batchReadList[batchReadN] = idx + end + if strokeOrig[idx] == nil then + strokeOrig[idx] = pre + strokeOrigN = strokeOrigN + 1 + strokeOrigList[strokeOrigN] = idx + end end + bNew[idx] = newHeight end end end end end - -- Trim scratch heightData using tracked max (avoids # on reused table) - for i = hIdx + 1, scratchHeightDataMax do - heightData[i] = nil - end - scratchHeightDataMax = hIdx - - if hIdx > 0 then - applyHeightChanges(heightData, hIdx) - pushSnapshotFromFlat(snapFlat, sCount) + if standalone then + flushBatch() end end @@ -2040,6 +2324,111 @@ local function applyAutoramp( end end +-- Hoisted handler: one message carries a whole tick of brush dabs (the widget's +-- extraState.sendStrokeDabs builds it). Clay planes for every dab are derived +-- from the pre-tick heightmap BEFORE the first dab lands, so a stroke deposits +-- per distance travelled instead of per tick split by the dab count -- and still +-- cannot rise more than HEIGHT_STEP * intensity within one tick, because every +-- plane in the batch came from the same untouched heights. +local strokeDabX, strokeDabZ, strokeDabA, strokeClayPlane = {}, {}, {}, {} +local function handleStroke(payload) + local parts = parseParts(payload) + local direction = tonumber(parts[1]) + local radius = tonumber(parts[2]) + local shape = parts[3] or "circle" + local curve = tonumber(parts[4]) or 1.0 + local heightMin = tonumber(parts[5]) + local heightMax = tonumber(parts[6]) + local intensity = tonumber(parts[7]) or 1.0 + local lengthScale = tonumber(parts[8]) or 1.0 + -- Clay flag: "1" = clay (one layer per stroke), "2" = clay with per-tick + -- build-up (Settings > Stroke > Clay build-up), anything else = off. + local clayMode = (parts[9] == "1" and 1) or (parts[9] == "2" and 2) or false + local dustMode = parts[10] == "1" + local opacity = tonumber(parts[11]) or 0.3 + local instant = parts[12] == "1" + -- Same sentinels as the per-dab message: "smooth" and "smudge" + -- ride the flatten slot as non-numeric values. + local localBlur = parts[13] == "smooth" + local localSmudge = parts[13] ~= nil and parts[13]:sub(1, 6) == "smudge" + local smudgeStart = localSmudge and parts[13]:sub(7, 7) == "1" + local flattenHeight = tonumber(parts[13]) + if parts[14] then + ringInnerRatio = max(0.05, min(0.95, tonumber(parts[14]) or 0.6)) + end + local nDabs = tonumber(parts[15]) or 0 + if not direction or not radius or nDabs < 1 then + return + end + + radius = max(MIN_RADIUS, min(MAX_RADIUS, radius)) + curve = max(0.1, min(5.0, curve)) + intensity = max(0.1, min(100.0, intensity)) + lengthScale = max(0.2, min(5.0, lengthScale)) + opacity = max(0.01, min(1.0, opacity)) + + -- Copy the dabs out of the shared parse scratch before applying any of them. + local count = 0 + for i = 1, nDabs do + local b = 15 + (i - 1) * 3 + local x = tonumber(parts[b + 1]) + local z = tonumber(parts[b + 2]) + if not x or not z then + break + end + count = count + 1 + strokeDabX[count] = x + strokeDabZ[count] = z + strokeDabA[count] = tonumber(parts[b + 3]) or 0 + end + if count < 1 then + return + end + + -- Every plane of the tick is derived before any dab lands, so dabs in one + -- tick cannot compound on each other (see clayPlaneFor for the reference). + local doClay = clayMode and direction ~= 0 and direction ~= 2 + if doClay then + local rise = direction * HEIGHT_STEP * intensity + local stack = clayMode == 2 + for i = 1, count do + strokeClayPlane[i] = clayPlaneFor(strokeDabX[i], strokeDabZ[i], radius, rise, stack) + end + end + -- One batch for the tick: a single heightmap commit and a single undo entry + -- however many dabs the message carries. + beginBatch() + for i = 1, count do + applyTerraform( + strokeDabX[i], + strokeDabZ[i], + radius, + direction, + shape, + strokeDabA[i], + curve, + heightMin, + heightMax, + intensity, + lengthScale, + clayMode, + opacity, + flattenHeight, + instant, + localBlur, + localSmudge, + smudgeStart, + doClay and strokeClayPlane[i] or nil + ) + end + flushBatch() + -- One dust burst per tick rather than one per dab: up to 48 CEG spawns a tick + -- cost frames and looked no different. + if dustMode then + spawnDust(strokeDabX[count], strokeDabZ[count], radius, intensity) + end +end + -- Hoisted handler: the RecvLuaMsg dispatcher sits near the 60-upvalue cap, so -- the parse/clamp body lives here and the dispatcher only gains two upvalues. local function handleAutoramp(payload) @@ -2640,6 +3029,7 @@ function gadget:RecvLuaMsg(msg, playerID) if msg == STROKE_END_HEADER then finalizeMerge() currentStrokeId = currentStrokeId + 1 + clearStrokeOrigin() return true end @@ -2977,6 +3367,17 @@ function gadget:RecvLuaMsg(msg, playerID) return true end + -- Header spelled inline, not via the STROKE_HEADER local: this dispatcher is + -- one upvalue under the Lua 5.1 cap of 60, and a string constant costs none. + if msg:sub(1, 18) == "$terraform_stroke$" then + if not isTerraformAllowed(certified, playerID) then + echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") + return true + end + handleStroke(msg:sub(19)) + return true + end + if msg:sub(1, #PACKET_HEADER) ~= PACKET_HEADER then return end @@ -3000,7 +3401,7 @@ function gadget:RecvLuaMsg(msg, playerID) local heightMax = tonumber(parts[9]) local intensity = tonumber(parts[10]) or 1.0 local lengthScale = tonumber(parts[11]) or 1.0 - local clayMode = parts[12] == "1" + local clayMode = (parts[12] == "1" and 1) or (parts[12] == "2" and 2) or false local dustMode = parts[13] == "1" local opacity = tonumber(parts[14]) or 0.3 local instant = parts[15] == "1" diff --git a/luarules/gadgets/game_end.lua b/luarules/gadgets/game_end.lua index 35fa67bc94a..84f7830f8ea 100644 --- a/luarules/gadgets/game_end.lua +++ b/luarules/gadgets/game_end.lua @@ -240,7 +240,12 @@ if gadgetHandler:IsSyncedCode() then end function gadget:Initialize() - if Spring.GetModOptions().deathmode == "neverend" then + -- editor_sandbox=1 is the map editor's start-script flag (New Map / Open + -- Project): the session must never end, whatever deathmode it inherited. + if + Spring.GetModOptions().deathmode == "neverend" + or tostring(Spring.GetModOptions().editor_sandbox or "") == "1" + then gadgetHandler:RemoveGadget(self) return end diff --git a/luarules/gadgets/game_initial_spawn.lua b/luarules/gadgets/game_initial_spawn.lua index 89a7223cdd6..d609c82a2a2 100644 --- a/luarules/gadgets/game_initial_spawn.lua +++ b/luarules/gadgets/game_initial_spawn.lua @@ -451,12 +451,12 @@ if gadgetHandler:IsSyncedCode() then if type == 2 then return not ( - Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 0, true, false) - and Spring.TestMoveOrder(unitDefID, x, y, z, 1, 0, 0, true, false) - and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 1, true, false) - and Spring.TestMoveOrder(unitDefID, x, y, z, -1, 0, 0, true, false) - and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, -1, true, false) - ) or hasBlockingFeature(x, z, unitDefID) + Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 0, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, 1, 0, 0, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 1, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, -1, 0, 0, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, -1, true, false) + ) or hasBlockingFeature(x, z, unitDefID) end return Spring.TestBuildOrder(unitDefID, x, y, z, "s") == 0 @@ -593,6 +593,14 @@ if gadgetHandler:IsSyncedCode() then end end + -- Map editor sessions (New Map / Open Project) start with editor_sandbox=1 + -- in the start script: the map maker edits an empty canvas or a project's + -- own unit loadout, so no team gets a commander. Reuses the scenario + -- path so the spawn effects and warp-in skip as well. + if not scenarioSpawnsUnits and tostring(Spring.GetModOptions().editor_sandbox or "") == "1" then + scenarioSpawnsUnits = true + end + if not scenarioSpawnsUnits then if not (luaAI and (string.find(luaAI, "Scavengers") or luaAI == "RaptorsAI")) then local unitID = spCreateUnit(startUnit, x, y, z, 0, teamID) diff --git a/luarules/gadgets/game_team_com_ends.lua b/luarules/gadgets/game_team_com_ends.lua index f0d307538f0..a648f96a62d 100644 --- a/luarules/gadgets/game_team_com_ends.lua +++ b/luarules/gadgets/game_team_com_ends.lua @@ -154,6 +154,11 @@ function gadget:Initialize() then gadgetHandler:RemoveGadget(self) end + -- Map editor sessions (editor_sandbox=1 in the start script) have no + -- commanders at all; commander counting has nothing to end. + if tostring(Spring.GetModOptions().editor_sandbox or "") == "1" then + gadgetHandler:RemoveGadget(self) + end local allyTeamList = spGetAllyTeamList() for i = 1, #allyTeamList do diff --git a/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua b/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua index d7df5060ee4..ae715a18049 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua @@ -7,7 +7,8 @@ return { { name = "Clear Daylight", source = "Altair_Crossing_V4.1", - sunDir = { 0.8000, 0.8000, -0.7000 }, + -- sunDir + sunColor hand-set to PtaQ's canonical editor sun (2026-09-03); re-apply after a harvest + sunDir = { 0.4490, 0.5645, -0.6926 }, groundShadowDensity = 0.7500, modelShadowDensity = 0.7500, groundAmbientColor = { 0.5000, 0.5000, 0.5000 }, @@ -19,7 +20,7 @@ return { fogStart = 0.8000, fogEnd = 1.0000, fogColor = { 0.8000, 0.8000, 0.5000, 1.0000 }, - sunColor = { 1.0000, 0.9200, 0.7800 }, + sunColor = { 1.0000, 1.0000, 1.0000 }, skyColor = { 0.4288, 0.5802, 0.6400 }, cloudColor = { 0.9600, 0.9600, 0.9600 }, splatTexMults = { 1.2000, 0.7000, 0.5300, 0.5000 }, diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua index 2e5cdfc81d1..b152d9348fa 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua @@ -125,6 +125,13 @@ local function formatFrequency(f) end local WG = WG +-- Engine globals as chunk locals (the tf_* modules do the same): RmlUi event +-- closures can run outside the widget env where bare globals read nil, and +-- the CI analyzer counts every bare engine global as an undefined-global +-- finding. Same table objects, so Spring.X = ... still reaches every widget. +local Spring = Spring +local VFS = VFS +local gl = gl local GetViewGeometry = Spring.GetViewGeometry local GetMouseState = Spring.GetMouseState local TraceScreenRay = Spring.TraceScreenRay @@ -224,6 +231,7 @@ local windowDragAllWindows = {} widgetState = { -- forward-declared above playSound so mute check works rmlContext = nil, document = nil, + ---@type table? dmHandle = nil, rootElement = nil, modeButtons = {}, @@ -326,6 +334,11 @@ widgetState = { -- forward-declared above playSound so mute check works -- Passthrough mode: deactivate all tools but keep panel visible passthroughMode = false, passthroughSaved = nil, -- {tool=string, mode=string|nil} + -- Focus mode (game interface hidden, editor left alive): focusMode and + -- focusSetTimer are assigned by setFocusMode and deliberately NOT initialised + -- here. The analyzer takes a false/nil literal in this constructor as the + -- field's only value and flags every guard on it (the passthroughMode ones + -- above are all in the baseline for that reason). -- Settings window settingsRootEl = nil, settingsOpen = false, @@ -347,6 +360,9 @@ widgetState = { -- forward-declared above playSound so mute check works projectDeleteConfirmExpiry = 0, projectOpenRowEls = {}, -- {{slug = ..., el = ...}, ...} for selection painting projectOpenNeedsRebuild = false, -- set by a delete, consumed in Update + projectOpenFilter = "", -- search box text (lowercased substring match on name/path/size) + projectOpenSort = "recent", -- "recent" (last touched) | "name" | "size" + projectOpenCollapsed = {}, -- folder path -> true while its tree node is folded -- Auto-scroll transport state (per-slider, keyed by slider element id) transports = {}, -- Currently focused RmlUI input element (text/number boxes); cleared on blur. @@ -378,6 +394,8 @@ widgetState = { -- forward-declared above playSound so mute check works seenLightsTypeHint = false, seenCloneLayersHint = false, seenSceneSkyboxHint = false, + perfMode = false, -- Settings > Performance + clayStack = false, -- Settings > Stroke > Clay build-up (legacy per-tick stacking) heightmapExportRangeMode = "auto", heightmapExportCustomMin = 0, heightmapExportCustomMax = 1, @@ -398,6 +416,28 @@ widgetState = { -- forward-declared above playSound so mute check works -- first call, then serves subsequent calls from widgetState.elCache. Caches -- are invalidated in widget:Shutdown when the document closes. `nil` lookups -- are NOT cached (so late-loaded elements can be found on subsequent frames). +-- Give an RmlUi text field the keyboard. Without this the game eats every +-- keystroke and the field never types: SDL text input has to be started while +-- the field has focus, and WG.TerraformBrushInputFocused is what tells the tool +-- widgets to stand their single-letter hotkeys down. Every +-- in the panel must go through here -- the search boxes shipped without it and +-- were simply dead (reported by Moose, 2026-09-04). +widgetState.wireTextInput = function(el) + if not el then + return + end + el:AddEventListener("focus", function(_e) + WG.TerraformBrushInputFocused = true + Spring.SDLStartTextInput() + widgetState.focusedRmlInput = el + end, false) + el:AddEventListener("blur", function(_e) + WG.TerraformBrushInputFocused = false + Spring.SDLStopTextInput() + widgetState.focusedRmlInput = nil + end, false) +end + local function getCachedEl(doc, id) local cache = widgetState.elCache local el = cache[id] @@ -460,6 +500,12 @@ function loadUiPrefs() if type(data.disableTips) == "boolean" then widgetState.uiPrefs.disableTips = data.disableTips end + if type(data.perfMode) == "boolean" then + widgetState.uiPrefs.perfMode = data.perfMode + end + if type(data.clayStack) == "boolean" then + widgetState.uiPrefs.clayStack = data.clayStack + end if type(data.seenInstrumentsHint) == "boolean" then widgetState.uiPrefs.seenInstrumentsHint = data.seenInstrumentsHint end @@ -533,7 +579,7 @@ function saveUiPrefs() end f:write( string.format( - "return {\n\tdisableTips = %s,\n\tseenInstrumentsHint = %s,\n\tseenSplatDisplayHint = %s,\n\tseenStartposShapeHint = %s,\n\tseenMetalStampHint = %s,\n\tseenMetalMapHint = %s,\n\tseenFeaturesFiltersHint = %s,\n\tseenGrassColorFilterHint = %s,\n\tseenSplatFiltersHint = %s,\n\tseenWeatherPersistHint = %s,\n\tseenLightsTypeHint = %s,\n\tseenCloneLayersHint = %s,\n\tseenSceneSkyboxHint = %s,\n\theightmapExportRangeMode = %q,\n\theightmapExportCustomMin = %.6f,\n\theightmapExportCustomMax = %.6f,\n\twindowPositions = {\n", + "return {\n\tdisableTips = %s,\n\tseenInstrumentsHint = %s,\n\tseenSplatDisplayHint = %s,\n\tseenStartposShapeHint = %s,\n\tseenMetalStampHint = %s,\n\tseenMetalMapHint = %s,\n\tseenFeaturesFiltersHint = %s,\n\tseenGrassColorFilterHint = %s,\n\tseenSplatFiltersHint = %s,\n\tseenWeatherPersistHint = %s,\n\tseenLightsTypeHint = %s,\n\tseenCloneLayersHint = %s,\n\tseenSceneSkyboxHint = %s,\n\tperfMode = %s,\n\tclayStack = %s,\n\theightmapExportRangeMode = %q,\n\theightmapExportCustomMin = %.6f,\n\theightmapExportCustomMax = %.6f,\n\twindowPositions = {\n", tostring(widgetState.uiPrefs.disableTips and true or false), tostring(widgetState.uiPrefs.seenInstrumentsHint and true or false), tostring(widgetState.uiPrefs.seenSplatDisplayHint and true or false), @@ -547,6 +593,8 @@ function saveUiPrefs() tostring(widgetState.uiPrefs.seenLightsTypeHint and true or false), tostring(widgetState.uiPrefs.seenCloneLayersHint and true or false), tostring(widgetState.uiPrefs.seenSceneSkyboxHint and true or false), + tostring(widgetState.uiPrefs.perfMode and true or false), + tostring(widgetState.uiPrefs.clayStack and true or false), widgetState.uiPrefs.heightmapExportRangeMode or "auto", tonumber(widgetState.uiPrefs.heightmapExportCustomMin) or 0, tonumber(widgetState.uiPrefs.heightmapExportCustomMax) or 1 @@ -568,6 +616,53 @@ end widgetState.saveUiPrefs = saveUiPrefs +-- Settings > Performance and Stroke > Clay build-up live in ui_prefs and in +-- the brush widget: mirror the prefs into the data model and push them to +-- the widget. Idempotent; called on toggle, after the prefs load, and once +-- from Update if the widget shows up after this panel (load order is not +-- fixed between LuaUI widget folders). +widgetState.pushPerfPrefs = function() + local up = widgetState.uiPrefs or {} + local perf = up.perfMode and true or false + local stack = up.clayStack and true or false + local d = widgetState.dmHandle + if d then + if d.perfModeActive ~= perf then + d.perfModeActive = perf + d.perfModeStr = perf and "ON" or "OFF" + end + if d.clayStackActive ~= stack then + d.clayStackActive = stack + d.clayStackStr = stack and "ON" or "OFF" + end + end + widgetState.perfMode = perf + ---@type table? + local tb = WG.TerraformBrush + if tb and tb.setPerfMode then + tb.setPerfMode(perf) + tb.setClayStack(stack) + widgetState.perfPrefsPushed = true + else + widgetState.perfPrefsPushed = false + end +end + +-- The terraform mirror in Update (900 lines of per-frame readout, slider +-- and class syncing that dirties RmlUi) is not being read while the brush +-- is down on the world: stride it to every 4th draw frame during a sculpt +-- drag, and always under performance mode. A hover over the panel ends the +-- stride so its controls answer at frame rate. +widgetState.mirrorStrided = function(tfState) + if not (tfState.dragging or widgetState.perfMode) then + return false + end + if widgetState.mouseOverPanel then + return false + end + return Spring.GetDrawFrame() % 4 ~= 0 +end + function widgetState.restoreWindowPosition(rootId, rootEl) local pos = widgetState.uiPrefs.windowPositions[rootId] if not pos or not rootEl then @@ -814,6 +909,9 @@ local function tickSkyDynamic(dt) setSlLb(skyDynamic.sunSliderY, skyDynamic.sunLabelY, sy) setSlLb(skyDynamic.sunSliderZ, skyDynamic.sunLabelZ, sz) uiState.updatingFromCode = false + if widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + end end end end @@ -907,7 +1005,9 @@ local function applySkybox(texturePath) -- Spring.SetSkyBoxTexture looks up by CNamedTextures, which requires the path -- to be registered via gl.Texture first. gl.Texture can only be called from -- Draw call-ins. RmlUI click handlers fire from Update, so we defer: store the - -- path in a pending field and do gl.Texture + SetSkyBoxTexture in DrawScreen. + -- path in a pending field and do gl.Texture + SetSkyBoxTexture in the + -- DrawScreenPost drain (drainDeferredApplies; DrawScreen is skipped while the + -- interface is hidden, and FOCUS MODE hides it on purpose). widgetState._pendingSkyboxPath = normalized end widgetState.applySkybox = applySkybox @@ -1366,6 +1466,33 @@ local function _tbFindAnglePresetIdx(val) end return best end +-- FOLLOW STROKE applies to the terrain sculpt drag only: the other tools in the +-- SHAPE row (metal, grass, features, splat) stamp rather than stroke, and ramp / +-- noise / autoramp / restore / erode own their own sampling. +local _tbFollowModes = { raise = true, lower = true, level = true, smooth = true, smudge = true } +-- PASSABILITY overlay (MrBob's F6 check without a selected unit): the tileset +-- shader tints everything steeper than the class's max slope in the engine's +-- impassable purple, so a cliff can be judged while sculpting. Degrees are read +-- off a representative unit's movedef so the band matches what F6 draws; the +-- literals are gamedata/movedefs.lua's own SLOPE values as a fallback. +local _tbPassClasses = { + { key = "BOT", unit = "armpw", deg = 54 }, + { key = "VEH", unit = "armflash", deg = 27 }, + { key = "HOVER", unit = "corch", deg = 33 }, + { key = "AMPH", unit = "coramph", deg = 54 }, +} +local _tbPassIdx = 0 -- 0 = off +local function _tbPassDeg(entry) + ---@diagnostic disable-next-line: undefined-global + local ud = UnitDefNames and UnitDefNames[entry.unit] + local ms = ud and ud.moveDef and ud.moveDef.maxSlope + -- movedef maxSlope is stored as 1 - cos(angle), same space as + -- Spring.GetGroundNormal's fourth return. + if ms and ms > 0 and ms < 2 then + return math.deg(math.acos(1 - ms)) + end + return entry.deg +end local function _tbMirrorToggle(P, stateKey, setter, dmKey) if not WG.TerraformBrush then return @@ -1378,6 +1505,204 @@ local function _tbMirrorToggle(P, stateKey, setter, dmKey) end playSound("tick") end +-- ── IMAGE overlay (DISPLAY > Image) ────────────────────────────────────────── +-- One overlay shared by every tool's DISPLAY row (WG.TerraformImageOverlay, +-- cmd_terraform_image_overlay.lua). The chips toggle it or open the single +-- IMAGE OVERLAY floating window (tf-imgov-root); the helpers sit on one table +-- to stay clear of the main chunk's local budget. +local _imgOv = {} +-- { slider id suffix, state -> slider value, slider value -> overlay setter } +_imgOv.SLIDERS = { + { + "opacity", + function(s) + return (s.opacity or 0) * 100 + end, + function(v, IO) + IO.setOpacity(v / 100) + end, + }, + { + "offx", + function(s) + return (s.offsetX or 0) * 100 + end, + function(v, IO) + IO.setOffset(v / 100, nil) + end, + }, + { + "offy", + function(s) + return (s.offsetZ or 0) * 100 + end, + function(v, IO) + IO.setOffset(nil, v / 100) + end, + }, + { + "scale", + function(s) + return (s.scale or 1) * 100 + end, + function(v, IO) + IO.setScale(v / 100) + end, + }, +} +function _imgOv.active() + ---@type table? + local IO = WG.TerraformImageOverlay + return (IO and IO.isEnabled()) or false +end +function _imgOv.esc(s) + return (tostring(s):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) +end +-- Rebuild the file rows in the window (same row markup as the feature-map list). +function _imgOv.rebuildList(rescan) + local doc = widgetState.document + if not doc then + return + end + local listEl = doc:GetElementById("imgov-list") + if not listEl then + return + end + ---@type table? + local IO = WG.TerraformImageOverlay + listEl.inner_rml = "" + if not IO then + listEl.inner_rml = '
Image Overlay widget is not loaded (Settings > Widgets).
' + return + end + local files = IO.list(rescan) or {} + if #files == 0 then + listEl.inner_rml = '
No images in ' + .. _imgOv.esc(IO.getDir()) + .. " yet. Drop some in and hit Rescan folder.
" + return + end + local current = (IO.getState() or {}).file + for _, name in ipairs(files) do + local item = doc:CreateElement("div") + item:SetClass("tf-hm-row", true) + if name == current then + item:SetClass("imgov-current", true) + end + item.inner_rml = '
' .. _imgOv.esc(name) .. "
" + item:AddEventListener("click", function(ev) + ---@type table? + local api = WG.TerraformImageOverlay + if api then + local ok = api.select(name) + playSound(ok and "apply" or "toggleOff") + end + _imgOv.rebuildList(false) + ev:StopPropagation() + end, false) + listEl:AppendChild(item) + end +end +-- Push the overlay placement into the window sliders, skipping the one being +-- dragged (the drag ids come from the SNAP_SLIDERS registration). +function _imgOv.stamp(force) + local doc = widgetState.document + ---@type table? + local IO = WG.TerraformImageOverlay + if not doc or not IO then + return + end + local s = IO.getState() or {} + local cache = widgetState.imgOvLastVal + if not cache then + cache = {} + widgetState.imgOvLastVal = cache + end + local ds = uiState.draggingSlider + local stamped = false + uiState.updatingFromCode = true + for _, row in ipairs(_imgOv.SLIDERS) do + local id = "imgov-slider-" .. row[1] + if ds ~= ("imgov-" .. row[1]) then + local str = tostring(math.floor(row[2](s) + 0.5)) + if force or cache[id] ~= str then + cache[id] = str + local sl = doc:GetElementById(id) + if sl then + sl:SetAttribute("value", str) + stamped = true + end + local nb = doc:GetElementById(id .. "-numbox") + if nb then + nb:SetAttribute("value", str .. "%") + end + end + end + end + uiState.updatingFromCode = false + -- The change events these stamps raise land on a later frame (see + -- onTilesetKnob); onImgOvSlider drops them by this timestamp. + if stamped then + uiState.imgOvStampFrame = Spring.GetDrawFrame() + end +end +-- Numbox readout next to one placement slider ("37%"). +function _imgOv.setNumbox(key, str) + local doc = widgetState.document + if not doc or not key then + return + end + local nb = doc:GetElementById("imgov-slider-" .. key .. "-numbox") + if nb then + nb:SetAttribute("value", str .. "%") + end +end +function _imgOv.setWindow(open) + local dm = widgetState.dmHandle + if dm then + dm.imgOvVisible = open and true or false + end + if open then + _imgOv.rebuildList(true) + _imgOv.stamp(true) + end +end +-- Flip the overlay on/off; false when nothing is loaded yet. +function _imgOv.toggleShow() + ---@type table? + local IO = WG.TerraformImageOverlay + if not IO or not IO.hasImage() then + return false + end + local nv = not IO.isEnabled() + IO.setEnabled(nv) + local dm = widgetState.dmHandle + if dm then + dm.tbImgActive = nv + end + playSound(nv and "toggleOn" or "toggleOff") + return true +end +-- Per frame: chip state for every DISPLAY row, window readouts while it is up. +function _imgOv.sync(setDm) + ---@type table? + local IO = WG.TerraformImageOverlay + local s = IO and IO.getState() or nil + setDm("tbImgActive", (s and s.enabled and s.hasImage) or false) + local dm = widgetState.dmHandle + if not (dm and dm.imgOvVisible) then + return + end + setDm("imgOvHasImage", (s and s.hasImage) or false) + setDm("imgOvFileStr", (s and s.file) or "none") + setDm("imgOvSizeStr", (s and s.hasImage) and (tostring(s.width) .. " x " .. tostring(s.height) .. " px") or "") + setDm("imgOvError", (s and s.error) or (IO and "" or "Image Overlay widget is not loaded")) + setDm("imgOvFit", (s and s.fit) or "stretch") + setDm("imgOvFlipH", (s and s.flipH) or false) + setDm("imgOvFlipV", (s and s.flipV) or false) + setDm("imgOvSupported", not (s and s.supported == false)) + _imgOv.stamp(false) +end local function _deactivateAllTools() if WG.TerraformBrush then WG.TerraformBrush.deactivate() @@ -1581,9 +1906,11 @@ end -- block in sync with tools/mapgen/scan_environments.py / env_presets.lua. widgetState.newMapEnvPresets = { { + -- sunDir + sunColor = PtaQ's canonical editor sun (2026-09-03), see + -- envSunPresets[1]; the rest is the harvested Altair Crossing mood. name = "Clear Daylight", source = "Altair_Crossing_V4.1", - sunDir = { 0.8000, 0.8000, -0.7000 }, + sunDir = { 0.4490, 0.5645, -0.6926 }, groundShadowDensity = 0.7500, modelShadowDensity = 0.7500, groundAmbientColor = { 0.5000, 0.5000, 0.5000 }, @@ -1595,7 +1922,7 @@ widgetState.newMapEnvPresets = { fogStart = 0.8000, fogEnd = 1.0000, fogColor = { 0.8000, 0.8000, 0.5000, 1.0000 }, - sunColor = { 1.0000, 0.9200, 0.7800 }, + sunColor = { 1.0000, 1.0000, 1.0000 }, skyColor = { 0.4288, 0.5802, 0.6400 }, cloudColor = { 0.9600, 0.9600, 0.9600 }, splatTexMults = { 1.2000, 0.7000, 0.5300, 0.5000 }, @@ -1966,27 +2293,19 @@ do end Spring.Echo("[Terraform Brush] Environment presets: " .. #widgetState.newMapEnvPresets) end --- Environment a fresh map starts with. 0 = Default (keep engine defaults), 1..N --- = preset. This USED to default to 0, but "engine defaults" is placeholder --- lighting: ground ambient and diffuse both a flat 0.5, against ~0.99 diffuse on --- a real BAR daylight map, so a new map receives roughly 60% of the light one --- should. A baked map texture carries the mapper's own brightness and hides that; --- the tileset shader draws raw PBR albedo and cannot, so new maps read as though --- the SHADER were broken (diagnosed 2026-08-12 — /tileset probe reported --- flat-lit 0.596 against ~0.91 for the preset below). Start from a real harvested --- mood instead; Default stays selectable in the wizard. --- Resolved by NAME, not index: a regenerated env_presets.lua replaces this list --- wholesale and can reorder it, and silently defaulting to whatever landed in --- slot 1 would be worse than the engine defaults we are replacing. +-- Environment a fresh map starts with. 0 = Default, 1..N = a harvested mood. +-- Default does NOT mean "leave the engine lighting alone": the engine's is +-- placeholder lighting, ground ambient and diffuse both a flat 0.5 against ~0.99 +-- diffuse on a real BAR daylight map, so a new map receives roughly 60% of the +-- light it should. A baked map texture carries the mapper's own brightness and +-- hides that; the tileset shader draws raw PBR albedo and cannot, so new maps read +-- as though the SHADER were broken (diagnosed 2026-08-12 — /tileset probe +-- reported flat-lit 0.596 against ~0.91 for a real daylight mood). So Default +-- applies the canonical sun instead (widgetState.newMapDefaultEnv below): the +-- wizard opens on "Default" and a fresh map is still properly lit. The harvested +-- moods stay in the picker for anyone who wants one, and picking a mood brings its +-- water, fog and sky too, which Default deliberately leaves alone. widgetState.newMapEnvIdx = 0 -do - for i, p in ipairs(widgetState.newMapEnvPresets) do - if p.name == "Clear Daylight" then - widgetState.newMapEnvIdx = i - break - end - end -end -- Push the selected environment name into the data-model label. widgetState._nmRefreshEnvLabel = function() @@ -2036,6 +2355,21 @@ widgetState.refreshEnvSunSliders = function() uiState.updatingFromCode = false end +-- Same for the AZIMUTH / ELEVATION pair. Kept separate from the XYZ refresh so a +-- drag on either pair only restamps the other (restamping the slider under the +-- pointer fights the drag). +widgetState.refreshEnvSunAzEl = function() + local sx, sy, sz = gl.GetSun("pos") + if not sx then + return + end + local az, el = widgetState.azElFromSunDir(sx, sy, sz) + uiState.updatingFromCode = true + _envSetSlider("slider-env-sun-az", "lbl-env-sun-az", math.floor(az * 10 + 0.5), string.format("%.1f", az)) + _envSetSlider("slider-env-sun-el", "lbl-env-sun-el", math.floor(el * 10 + 0.5), string.format("%.1f", el)) + uiState.updatingFromCode = false +end + -- Apply a full environment config table (schema = env_presets.lua / onEnvSave) to -- the live engine. Mirrors onEnvLoad's apply body so the env editor and the New -- Map preset path drive the engine identically. Every field is optional. @@ -2048,10 +2382,15 @@ widgetState.applyEnvConfig = function(d) -- A config saved while gl.GetSun returned nothing carries {0,0,0}: applying -- it would black out the map, so a degenerate direction is ignored. if sdx * sdx + sdy * sdy + sdz * sdz > 1e-6 then - local intensity = d.sunIntensity or 1.0 + -- A config without an intensity (the harvested map moods have none) + -- keeps the session's; only an explicit value changes it. + local intensity = d.sunIntensity or widgetState.envSunIntensity or 1.0 Spring.SetSunDirection(sdx, sdy, sdz, intensity) widgetState.envSunIntensity = intensity widgetState.refreshEnvSunSliders() + if widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + end end end local shadowParams = {} @@ -2086,6 +2425,17 @@ widgetState.applyEnvConfig = function(d) if next(lightParams) then Spring.SetSunLighting(lightParams) Spring.SendCommands("luarules updatesun") + -- A skybox fade in flight scales the six sun colours from its captured + -- originals and restores those at the end, which would overwrite what + -- was just applied: retarget the fade at the new colours instead. + if skyFade.active then + skyFade.origGroundAmbient = lightParams.groundAmbientColor or skyFade.origGroundAmbient + skyFade.origGroundDiffuse = lightParams.groundDiffuseColor or skyFade.origGroundDiffuse + skyFade.origGroundSpecular = lightParams.groundSpecularColor or skyFade.origGroundSpecular + skyFade.origUnitAmbient = lightParams.unitAmbientColor or skyFade.origUnitAmbient + skyFade.origUnitDiffuse = lightParams.unitDiffuseColor or skyFade.origUnitDiffuse + skyFade.origUnitSpecular = lightParams.unitSpecularColor or skyFade.origUnitSpecular + end end local atmosParams = {} -- Env-preset fog intentionally NOT applied (placeholder + obscuring): force it off. @@ -2160,6 +2510,239 @@ widgetState.applyEnvConfig = function(d) t.element:SetClass("active", t.path == d.skybox) end end + -- The ENV panel's RESET buttons return to "the defaults": after a project + -- or preset apply those are the applied values, not whatever the engine + -- held when the panel first opened (often the flat blank-map lighting). + if widgetState.captureEnvDefaults then + widgetState.captureEnvDefaults() + end +end + +-- Sun direction <-> azimuth/elevation (degrees). Azimuth is compass-like on the +-- map: 0 = north (toward -Z, the top of the minimap), 90 = east (+X). +-- Elevation is the angle above the horizon. sunDir points AT the sun. +widgetState.sunDirFromAzEl = function(azDeg, elDeg) + local az, el = math.rad(azDeg or 0), math.rad(math.max(0.5, math.min(89.5, elDeg or 45))) + local c = math.cos(el) + return c * math.sin(az), math.sin(el), -c * math.cos(az) +end +widgetState.azElFromSunDir = function(x, y, z) + local len = math.sqrt((x or 0) ^ 2 + (y or 0) ^ 2 + (z or 0) ^ 2) + if len < 1e-6 then + return 0, 45 + end + local el = math.deg(math.asin(math.max(-1, math.min(1, (y or 0) / len)))) + local az = math.deg(math.atan2(x or 0, -(z or 0))) + if az < 0 then + az = az + 360 + end + return az, el +end + +-- Sun-only quick presets for the ENV panel: azimuth, elevation, intensity, the +-- six sun colours, the sun tint and both shadow densities. They never touch +-- water, fog or sky, so they are safe on any map. Three on purpose (PtaQ, +-- 2026-09-03): the canonical sun, a low warm one and a flat one. +widgetState.envSunPresets = { + { + -- PtaQ's canonical editor sun (Terraform Brush/Environments/Canonical sun.lua, + -- 2026-09-03): the default here and the New Map wizard's Clear Daylight sun. + name = "Canonical", + az = 33, + el = 34.4, + sunIntensity = 1.0, + groundAmbientColor = { 0.5, 0.5, 0.5 }, + groundDiffuseColor = { 0.99, 0.99, 0.95 }, + groundSpecularColor = { 0.7, 0.7, 0.7 }, + unitAmbientColor = { 0.56, 0.56, 0.6 }, + unitDiffuseColor = { 0.95, 0.955, 0.9 }, + unitSpecularColor = { 0.8, 0.6, 0.6 }, + sunColor = { 1.0, 1.0, 1.0 }, + groundShadowDensity = 0.75, + modelShadowDensity = 0.75, + }, + { + name = "Dusk", + az = 272, + el = 10, + sunIntensity = 0.85, + groundAmbientColor = { 0.4, 0.36, 0.46 }, + groundDiffuseColor = { 1.0, 0.66, 0.45 }, + groundSpecularColor = { 0.6, 0.45, 0.4 }, + unitAmbientColor = { 0.46, 0.42, 0.52 }, + unitDiffuseColor = { 1.0, 0.72, 0.52 }, + unitSpecularColor = { 0.8, 0.55, 0.45 }, + sunColor = { 1.0, 0.62, 0.36 }, + groundShadowDensity = 0.55, + modelShadowDensity = 0.55, + }, + { + name = "Overcast", + az = 180, + el = 58, + sunIntensity = 0.75, + groundAmbientColor = { 0.62, 0.63, 0.66 }, + groundDiffuseColor = { 0.72, 0.74, 0.77 }, + groundSpecularColor = { 0.4, 0.4, 0.42 }, + unitAmbientColor = { 0.64, 0.65, 0.68 }, + unitDiffuseColor = { 0.75, 0.77, 0.8 }, + unitSpecularColor = { 0.5, 0.5, 0.52 }, + sunColor = { 0.85, 0.87, 0.9 }, + groundShadowDensity = 0.35, + modelShadowDensity = 0.35, + }, +} + +-- The ENV panel's preset catalog: harvested map moods (the New Map wizard's +-- list), the user's own files in Terraform Brush/Environments/ (SAVE in the +-- panel; legacy Lightmaps/*_environ_*.lua saves are listed too), and the +-- sun-only quick presets above. Each entry = { name, kind, data | path }. +-- (Fields on widgetState, not chunk locals: the main chunk is near the Lua 5.1 +-- 200-local ceiling.) +widgetState.envPresetDir = "Terraform Brush/Environments/" +widgetState.listEnvPresets = function() + local ENV_PRESET_DIR = widgetState.envPresetDir + local out = {} + for _, p in ipairs(widgetState.envSunPresets) do + out[#out + 1] = { name = p.name, kind = "sun", data = p } + end + for _, p in ipairs(widgetState.newMapEnvPresets or {}) do + out[#out + 1] = { name = p.name, kind = "mood", data = p } + end + local user = {} + for _, f in ipairs(VFS.DirList(ENV_PRESET_DIR, "*.lua", VFS.RAW) or {}) do + local base = (f:match("([^/\\]+)%.lua$") or f) + user[#user + 1] = { name = base, kind = "user", path = f } + end + for _, f in ipairs(VFS.DirList("Terraform Brush/Lightmaps/", "*_environ_*.lua", VFS.RAW) or {}) do + local base = (f:match("([^/\\]+)%.lua$") or f) + user[#user + 1] = { name = base, kind = "user", path = f } + end + table.sort(user, function(a, b) + return a.name:lower() < b.name:lower() + end) + for _, u in ipairs(user) do + out[#out + 1] = u + end + return out +end + +-- Resolve an entry's config table (files load on demand, BOM-stripped: Recoil +-- runs stock Lua 5.1 and loadstring chokes on a UTF-8 BOM). +widgetState.loadEnvPresetData = function(entry) + if entry.data then + return entry.data + end + local raw = entry.path and VFS.LoadFile(entry.path, VFS.RAW) + if not raw or raw == "" then + return nil, "could not read " .. tostring(entry.path) + end + raw = raw:gsub("^\239\187\191", "") + local chunk = loadstring(raw) + if not chunk then + return nil, "parse failed for " .. tostring(entry.path) + end + local ok, d = pcall(chunk) + if not ok or type(d) ~= "table" then + return nil, "invalid data in " .. tostring(entry.path) + end + return d +end + +-- Apply a preset with the panel's scope. "sun" takes only the sun keys (a +-- sun-only preset has nothing else anyway); "full" hands the whole table to +-- applyEnvConfig. A sun-only preset's az/el become a sunDir first. +widgetState.envSunKeys = { + "sunDir", + "sunIntensity", + "groundShadowDensity", + "modelShadowDensity", + "groundAmbientColor", + "groundDiffuseColor", + "groundSpecularColor", + "unitAmbientColor", + "unitDiffuseColor", + "unitSpecularColor", + "sunColor", +} +widgetState.applyEnvPreset = function(entry, scope) + local d, err = widgetState.loadEnvPresetData(entry) + if not d then + Spring.Echo("[Environ] preset '" .. tostring(entry.name) .. "': " .. tostring(err)) + return false + end + if d.az and d.el and not d.sunDir then + local x, y, z = widgetState.sunDirFromAzEl(d.az, d.el) + local copy = {} + for k, v in pairs(d) do + copy[k] = v + end + copy.sunDir = { x, y, z } + d = copy + end + if scope == "sun" or entry.kind == "sun" then + local subset = {} + for _, k in ipairs(widgetState.envSunKeys) do + subset[k] = d[k] + end + d = subset + end + widgetState.applyEnvConfig(d) + widgetState.envPresetCurrent = entry.name + return true +end + +-- SAVE in the panel: the full live environment (buildEnvConfigContent) under a +-- user-chosen name, so it lists in every session and on every map. +widgetState.saveEnvPreset = function(name) + local trimmed = tostring(name or ""):match("^%s*(.-)%s*$") or "" + name = trimmed:gsub("[^%w_%- ]", "_") + if name == "" then + return false, "type a preset name first" + end + Spring.CreateDir(widgetState.envPresetDir) + local path = widgetState.envPresetDir .. name .. ".lua" + local f = io.open(path, "w") + if not f then + return false, "could not write " .. path + end + f:write(widgetState.buildEnvConfigContent()) + f:close() + Spring.Echo("[Environ] saved environment preset: " .. path) + return true, path +end + +-- /tf_sunlog: log every sun write (direction and lighting) with a traceback, so +-- "who reset my sun?" is answered by the console instead of by guessing. The +-- wrappers sit on the shared Spring table, so every LuaUI widget's writes show. +widgetState.setSunLog = function(on) + if on and not widgetState._sunLogOrig then + local orig = { dir = Spring.SetSunDirection, light = Spring.SetSunLighting } + widgetState._sunLogOrig = orig + Spring.SetSunDirection = function(x, y, z, i) + Spring.Echo( + string.format("[sunlog] SetSunDirection(%.3f, %.3f, %.3f, %s)", x or 0, y or 0, z or 0, tostring(i)) + ) + Spring.Echo(debug.traceback("", 2)) + return orig.dir(x, y, z, i) + end + Spring.SetSunLighting = function(t) + local keys = {} + for k in pairs(type(t) == "table" and t or {}) do + keys[#keys + 1] = tostring(k) + end + table.sort(keys) + Spring.Echo("[sunlog] SetSunLighting{" .. table.concat(keys, ", ") .. "}") + Spring.Echo(debug.traceback("", 2)) + return orig.light(t) + end + Spring.Echo("[Terraform Brush] sun write logging ON (/tf_sunlog again to stop)") + elseif not on and widgetState._sunLogOrig then + Spring.SetSunDirection = widgetState._sunLogOrig.dir + Spring.SetSunLighting = widgetState._sunLogOrig.light + widgetState._sunLogOrig = nil + Spring.Echo("[Terraform Brush] sun write logging OFF") + end end -- Serialize the live environment state into the env-config Lua format (the same @@ -2324,7 +2907,33 @@ widgetState.buildEnvConfigContent = function(opts) return table.concat(outLines, "\n") end --- Resolve the env preset to apply after a New Map reload (nil = Default/none). +-- The wizard's "Default" environment: PtaQ's canonical sun and nothing else, so a +-- fresh map is lit like a real one without adopting some other map's water, fog and +-- sky. Built from envSunPresets[1], the single place that sun is defined, rather +-- than from a copy: the harvested moods in env_presets.lua are regenerated by +-- tools/mapgen/scan_environments.py, so a sun stored there cannot be trusted to +-- survive a re-harvest. Lazy on purpose (envSunKeys is defined further down). +widgetState.newMapDefaultEnv = function() + local sun = widgetState.envSunPresets and widgetState.envSunPresets[1] + if not sun then + return nil + end + local x, y, z = widgetState.sunDirFromAzEl(sun.az, sun.el) + ---@type table + local out = { name = sun.name, sunDir = { x, y, z } } + for _, k in ipairs(widgetState.envSunKeys) do + local v = sun[k] + if k ~= "sunDir" and v ~= nil then + -- colours are copied element-wise: sharing the table would let an ENV + -- panel edit reach back into the preset + out[k] = (type(v) == "table") and { v[1], v[2], v[3] } or v + end + end + return out +end + +-- Resolve the env preset to apply after a New Map reload (nil = Default, which the +-- reader turns into newMapDefaultEnv above). widgetState._nmCurrentEnvPreset = function() local idx = widgetState.newMapEnvIdx or 0 if idx <= 0 then @@ -2492,10 +3101,16 @@ local function buildBlankMapStartScript(widthUnits, heightUnits, dntsSet, skybox -- game_team_com_ends remove themselves at init, so teams survive with zero -- units (edit without commanders) and commander death cannot end the session. script = script:gsub("[Dd][Ee][Aa][Tt][Hh][Mm][Oo][Dd][Ee]%s*=[^;\r\n]*;?", "") + -- editor_sandbox=1 marks the session as a map editor canvas for the game + -- gadgets: game_initial_spawn spawns no commanders (the map maker edits an + -- empty canvas or the project's own unit loadout), and game_end / + -- game_team_com_ends stand down whatever deathmode the lobby set. Strip an + -- inherited copy first so editor-to-editor reloads stay idempotent. + script = script:gsub("[Ee][Dd][Ii][Tt][Oo][Rr]_[Ss][Aa][Nn][Dd][Bb][Oo][Xx]%s*=[^;\r\n]*;?", "") local needModoptions = true local _, moE = script:find("%[[Mm][Oo][Dd][Oo][Pp][Tt][Ii][Oo][Nn][Ss]%]%s*\r?\n?%s*{") if moE then - script = script:sub(1, moE) .. "\ndeathmode=neverend;" .. script:sub(moE + 1) + script = script:sub(1, moE) .. "\ndeathmode=neverend;\neditor_sandbox=1;" .. script:sub(moE + 1) needModoptions = false end @@ -2563,6 +3178,7 @@ local function buildBlankMapStartScript(widthUnits, heightUnits, dntsSet, skybox injectParts[#injectParts + 1] = "[modoptions]" injectParts[#injectParts + 1] = "{" injectParts[#injectParts + 1] = "deathmode=neverend;" + injectParts[#injectParts + 1] = "editor_sandbox=1;" injectParts[#injectParts + 1] = "}" end local inject = table.concat(injectParts, "\n") @@ -2894,6 +3510,55 @@ function capUI.set(key, value) capUI.sync() end +-- "3 h ago" / "yesterday" / "2026-08-22" for the project lists. Manifests and +-- the recent-projects journal stamp ISO-8601 UTC; os.time() reads a table as +-- local time, so the parsed stamp is shifted by the local UTC offset. Dates a +-- week or older show as the (local) calendar day. On widgetState: the main +-- chunk is near the Lua 5.1 200-local ceiling. +widgetState.relativeAge = function(iso, now) + local stamp = tostring(iso or "") + local y, mo, d, h, mi, s = stamp:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):?(%d*)") + if not y then + return stamp ~= "" and stamp or "(no date)" + end + -- isdst = false on BOTH conversions: the stamp and the offset probe then go + -- through the same standard-time interpretation, so the offset cancels + -- exactly whatever the daylight-saving state of either date is. + local t = os.time({ + year = math.floor(tonumber(y) or 0), + month = math.floor(tonumber(mo) or 1), + day = math.floor(tonumber(d) or 1), + hour = math.floor(tonumber(h) or 0), + min = math.floor(tonumber(mi) or 0), + sec = math.floor(tonumber(s) or 0), + isdst = false, + }) + if not t then + return string.format("%s-%s-%s", y, mo, d) + end + local nowT = now or os.time() + local probe = os.date("!*t", nowT) + probe.isdst = false + local utcOffset = nowT - os.time(probe) + local epoch = t + utcOffset + local diff = nowT - epoch + if diff < 0 then + diff = 0 + end + if diff < 60 then + return "just now" + elseif diff < 3600 then + return string.format("%d min ago", math.floor(diff / 60)) + elseif diff < 86400 then + return string.format("%d h ago", math.floor(diff / 3600)) + elseif diff < 2 * 86400 then + return "yesterday" + elseif diff < 7 * 86400 then + return string.format("%d d ago", math.floor(diff / 86400)) + end + return os.date("%Y-%m-%d", epoch) +end + -- Opens the Save Project As dialog: prefills the name (current project > -- last-typed > slugified map name) and rebuilds the existing-projects list, -- where clicking a row fills the NAME field (pick-to-overwrite, modern Save @@ -2941,10 +3606,10 @@ widgetState.openProjectSaveDialog = function() return (tostring(s):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end local parts = {} + local now = os.time() for i, p in ipairs(projects) do - local stamp = tostring(p.modified or "") - local y, mo, dd, hh, mi = stamp:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+)") - local when = y and string.format("%s-%s-%s %s:%s", y, mo, dd, hh, mi) or (stamp ~= "" and stamp or "(no date)") + -- Nested projects show their path: that is what the NAME field receives. + local label = (p.folder and p.folder ~= "") and p.slug or (p.name or p.slug) parts[#parts + 1] = string.format( '
' .. '
%s
' @@ -2952,8 +3617,8 @@ widgetState.openProjectSaveDialog = function() .. '
%sx%s
' .. "
", i, - esc(when), - esc(p.name or p.slug), + esc(widgetState.relativeAge(p.modified, now)), + esc(label), esc(p.size_x or "?"), esc(p.size_z or "?") ) @@ -3005,6 +3670,7 @@ local initialModel = { -- Phase 2 step 3: data-if visibility flags (tf_guide pilot) passthroughActive = false, + focusActive = false, settingsOpen = false, settingsTab = "keybinds", -- Map Labels window (gui_map_labels widget) — header button highlight @@ -3045,6 +3711,7 @@ local initialModel = { projectSaveOpen = false, projectSaveHint = "", projectSaveUnits = false, -- "save units loadout" toggle (position/team of every unit) + projectOpenSort = "recent", -- Open Project sort chip: recent | name | size projectCurrentName = "", -- FILE > Save target ("" = none yet → Save acts as Save As) -- Open Project dialog (FILE > Open Project, backed by WG.MapProject) projectOpenOpen = false, @@ -3090,6 +3757,23 @@ local initialModel = { tsQuality = "high", tsDebugView = 0, -- active TILESET debug view (drives the DEBUG multi-toggle highlight) tsMetalStyle = "", -- active METAL SPOTS style tile (data-class-active="tsMetalStyle == ''") + tsGlowOn = false, -- METAL SPOTS glow light master (grays the GLOW LIGHT block via data-class-disabled) + -- HEIGHT TINT (tileset shader 0.27): axis mode chips, the selected colour + -- chip (grade stops / strata beds / snow) the shared palette + trio edits, + -- strata chip visibility + layer-mask chips, ramp mode chips + file label. + -- Synced from the knob table in tf_tileset.sync (syncHeightTint). + tsHgRef = 0, + tsHgTarget = "low", + tsHgTargetName = "GRADE LOW", + tsStrataCount = 4, + tsStrataBase = true, + tsStrataInter = true, + tsStrataCliff = true, + tsStrataPlat = true, + tsRampMode = 0, + tsRampFile = "none", + tsStopsCount = 3, -- GRADIENT STOPS chips shown (data-if) and the Multiply / Colorize chips + tsStopsMode = 1, -- SURFACE tool (tileset variant paint; engine = dev_surface_painter.lua, -- catalog/shader = dev_tileset_terrain.lua, UI module = tf_surface.lua) surfPreset = "dot", @@ -3157,8 +3841,16 @@ local initialModel = { surfHardOverlay = false, -- LAYERS: splat override channel overlay (engine flag mirror) -- SURFACE soft-submode smart filters (engine = dev_surface_painter) surfSoftAvoidWater = false, + -- INFLUENCE section (both submodes): chip state + the profile's owner + surfInfAlt = false, + surfInfSlope = false, + surfInfKey = "", surfSoftAvoidCliffs = false, surfSoftAltMin = false, + surfAltMinSample = false, + surfAltMaxSample = false, + surfInfAltMinSample = false, + surfInfAltMaxSample = false, surfSoftAltMax = false, -- WYSIWYG Ctrl sneak peek (DISPLAY chip, both submodes): holding Ctrl over -- the map renders the selected layer inside the brush ring as if the @@ -3532,6 +4224,8 @@ local initialModel = { seismicEffectsStr = "OFF", penPressureStr = "OFF", wiggleStr = "OFF", + perfModeStr = "OFF", -- Settings > Performance + clayStackStr = "OFF", -- Settings > Stroke > Clay build-up disableTipsStr = "OFF", keepAliveStr = "OFF", -- Settings > General: match end disabled for this session penSensitivityStr = "100", @@ -3541,6 +4235,8 @@ local initialModel = { seismicActive = false, penPressureActive = false, wiggleActive = false, + perfModeActive = false, + clayStackActive = false, disableTipsActive = false, keepAliveActive = false, -- Phase 2 step 6: sub-panel dj-disabled states (true = grayed out) @@ -3582,6 +4278,22 @@ local initialModel = { tfHeightColormap = false, tfCurveOverlay = false, tfVelocityIntensity = false, + tfFollowStroke = false, + tfFollowVisible = true, + -- PASSABILITY overlay: one shared state across every DISPLAY row + tbPassActive = false, + tbPassLabelStr = "Passability", + -- IMAGE overlay (DISPLAY > Image): one shared state across every DISPLAY row + tbImgActive = false, + imgOvVisible = false, + imgOvHasImage = false, + imgOvFileStr = "none", + imgOvSizeStr = "", + imgOvError = "", + imgOvFit = "stretch", + imgOvFlipH = false, + imgOvFlipV = false, + imgOvSupported = true, tfSymMirrorX = false, tfSymMirrorY = false, tfSymFlipped = false, @@ -3615,6 +4327,8 @@ local initialModel = { splatTexVisible = false, skyboxLibraryVisible = false, envSunVisible = false, + envPresetScope = "full", -- Sun & Shadows PRESETS: what a preset click applies ("sun" | "full") + envPresetHint = "", envFogVisible = false, envGroundLightingVisible = false, envUnitLightingVisible = false, @@ -6923,9 +7637,11 @@ local initialModel = { end return end - if not name:match("^[A-Za-z0-9_%-]+$") then + -- Coarse screen only; cmd_map_project's validateSlug is the rule (spaces + -- inside a segment are fine, / separates folders). + if not name:match("^[A-Za-z0-9_%- /]+$") then if d then - d.projectSaveHint = "Only letters, digits, - and _ (no spaces)." + d.projectSaveHint = "Only letters, digits, spaces, - and _; / for a folder." end return end @@ -7003,12 +7719,19 @@ local initialModel = { -- Clicking a row only selects it — LOAD and DELETE live at the bottom of -- the dialog, like Save Project and New Map. Neither belongs on a stray -- click in a list: one restarts the session, the other destroys files. + ---@type table? local doc = widgetState.document local listEl = doc and doc:GetElementById("tf-project-open-list") - if not listEl then + if not (doc and listEl) then return end local function rebuild() + if not doc then + return + end + -- The selection survives a folder toggle, a sort or a filter change; + -- it drops only when the selected project is no longer listed. + local keepSlug = tostring(widgetState.projectOpenSelectedSlug or "") widgetState.projectOpenRowEls = {} widgetState.projectOpenSelectedSlug = nil widgetState.projectDeleteConfirmExpiry = 0 @@ -7026,42 +7749,178 @@ local initialModel = { end return end - local projects = WG.MapProject.listDetailed() - if #projects == 0 then + local all = WG.MapProject.listDetailed() + if #all == 0 then listEl.inner_rml = '
' - .. "No projects found in MapProjects/. Projects saved this session may need an engine restart to appear (VFS folder cache).
" + .. "No projects found in MapProjects/. Projects saved this session may need an engine restart to appear (VFS folder cache). " + .. "To browse a shared maps repository, clone it inside that folder: git clone <url> MapProjects/<name>." return end local function esc(s) return (tostring(s):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end - local parts = {} - for i, p in ipairs(projects) do - -- Manifests stamp ISO-8601 UTC ("2026-07-27T14:22:31Z"); the heightmap - -- browser shows "YYYY-MM-DD HH:MM", so drop the seconds and the T/Z. - local stamp = tostring(p.modified or "") - local y, mo, dd, hh, mi = stamp:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+)") - local when = y and string.format("%s-%s-%s %s:%s", y, mo, dd, hh, mi) - or (stamp ~= "" and stamp or "(no date)") + local filter = tostring(widgetState.projectOpenFilter or ""):lower() + local sortMode = tostring(widgetState.projectOpenSort or "recent") + local now = os.time() + -- RECENT means last touched: the newer of "opened or saved through the + -- editor" (journal) and the manifest's modified stamp, both ISO-8601 so + -- string order is time order. + local function touched(p) + local a, b = tostring(p.last_touched or ""), tostring(p.modified or "") + return a > b and a or b + end + local function less(a, b) + if sortMode == "name" then + local an, bn = tostring(a.name or a.slug):lower(), tostring(b.name or b.slug):lower() + if an ~= bn then + return an < bn + end + elseif sortMode == "size" then + local aa = (tonumber(a.size_x) or 0) * (tonumber(a.size_z) or 0) + local bb = (tonumber(b.size_x) or 0) * (tonumber(b.size_z) or 0) + if aa ~= bb then + return aa > bb + end + else + local ta, tb = touched(a), touched(b) + if ta ~= tb then + return ta > tb + end + end + return a.slug < b.slug + end + -- Search: case-insensitive substring over the name, the path and the + -- NxN size, so "cm0", "campaign/" and "16x16" all work. + local projects = {} + for _, p in ipairs(all) do + if filter == "" then + projects[#projects + 1] = p + else + local hay = string.format("%s %s %sx%s", p.name or "", p.slug or "", p.size_x or "", p.size_z or "") + if hay:lower():find(filter, 1, true) then + projects[#projects + 1] = p + end + end + end + if #projects == 0 then + listEl.inner_rml = '
No project matches "' + .. esc(widgetState.projectOpenFilter) + .. '".
' + return + end + table.sort(projects, less) + local parts, rows, folders = {}, {}, {} + local collapsed = widgetState.projectOpenCollapsed or {} + local function projectRow(p, depth, showPath) + rows[#rows + 1] = p + local pathHtml = "" + if showPath and p.folder and p.folder ~= "" then + pathHtml = '
' .. esc(p.folder .. "/") .. "
" + end parts[#parts + 1] = string.format( - '
' + '
' .. '
%s
' - .. '
%s
' + .. '
%s
%s' .. '
%sx%s
' .. "
", - i, - esc(when), + #rows, + depth, + esc(widgetState.relativeAge(touched(p), now)), esc(p.name or p.slug), + pathHtml, esc(p.size_x or "?"), esc(p.size_z or "?") ) end + if filter ~= "" then + -- Flat while searching; the folder path travels with each row. + for _, p in ipairs(projects) do + projectRow(p, 0, true) + end + else + -- Tree: a folder's own projects first (in the chosen order), then its + -- subfolders. Every intermediate folder gets a node even when it + -- holds no project of its own, so a cloned repository's layout shows + -- as it is on disk. + local byFolder, children, count, newest = { [""] = {} }, {}, {}, {} + local function parentOf(path) + return path:match("^(.*)/[^/]+$") or "" + end + local function ensureFolder(path) + if path == "" or rawget(byFolder, path) then + return + end + byFolder[path] = {} + local parent = parentOf(path) + ensureFolder(parent) + children[parent] = children[parent] or {} + children[parent][#children[parent] + 1] = path + end + for _, p in ipairs(projects) do + local f = p.folder or "" + ensureFolder(f) + byFolder[f][#byFolder[f] + 1] = p + local t = touched(p) + local anc = f + while anc ~= "" do + count[anc] = (count[anc] or 0) + 1 + if t > (newest[anc] or "") then + newest[anc] = t + end + anc = parentOf(anc) + end + end + local function folderLess(a, b) + if sortMode == "recent" then + local na, nb = newest[a] or "", newest[b] or "" + if na ~= nb then + return na > nb + end + elseif sortMode == "size" then + local ca, cb = count[a] or 0, count[b] or 0 + if ca ~= cb then + return ca > cb + end + end + return a:lower() < b:lower() + end + local function render(path, depth) + for _, p in ipairs(byFolder[path] or {}) do + projectRow(p, depth, false) + end + local subs = children[path] or {} + table.sort(subs, folderLess) + for _, sub in ipairs(subs) do + local open = not collapsed[sub] + folders[#folders + 1] = sub + parts[#parts + 1] = string.format( + '
' + .. '
%s
' + .. '
%s/
' + .. '
%d
', + #folders, + depth, + open and "-" or "+", + esc(sub:match("([^/]+)$") or sub), + count[sub] or 0 + ) + if open then + render(sub, depth + 1) + end + end + end + render("", 0) + end listEl.inner_rml = table.concat(parts) - for i, p in ipairs(projects) do + for i, p in ipairs(rows) do local row = doc:GetElementById("tf-proj-r" .. i) if row then - local slug, label = p.slug, (p.name or p.slug) - widgetState.projectOpenRowEls[#widgetState.projectOpenRowEls + 1] = { slug = slug, el = row } + -- Nested projects select by their path so "Selected:" and the + -- console echoes say exactly what will open. + local slug = p.slug + local label = (p.folder and p.folder ~= "") and slug or (p.name or slug) + widgetState.projectOpenRowEls[#widgetState.projectOpenRowEls + 1] = + { slug = slug, label = label, el = row } row:AddEventListener("click", function(ev) ev:StopPropagation() playSound("click") @@ -7080,6 +7939,32 @@ local initialModel = { end, false) end end + for i, path in ipairs(folders) do + local fEl = doc:GetElementById("tf-proj-f" .. i) + if fEl then + fEl:AddEventListener("click", function(ev) + ev:StopPropagation() + playSound("click") + local c = widgetState.projectOpenCollapsed or {} + c[path] = (not c[path]) and true or nil + widgetState.projectOpenCollapsed = c + -- Rebuild next frame, not from inside the click on a row the + -- rebuild destroys. + widgetState.projectOpenNeedsRebuild = true + end, false) + end + end + if keepSlug ~= "" then + for _, r in ipairs(widgetState.projectOpenRowEls) do + if r.slug == keepSlug then + widgetState.projectOpenSelectedSlug = keepSlug + r.el:SetClass("selected", true) + if dm then + dm.projectOpenSelected = r.label + end + end + end + end end -- Stashed on widgetState (not a chunk local) so the bottom buttons can -- refresh the list after a delete. @@ -7163,6 +8048,37 @@ local initialModel = { -- Never leave DELETE armed for the next time the dialog opens. widgetState.projectDeleteConfirmExpiry = 0 end, + -- Open Project search box (change fires per keystroke) and sort chips. All + -- three queue the deferred rebuild rather than rebuilding here: the list is + -- torn down and rebuilt, which must not happen inside an event dispatch. + onProjectSearch = function(_event) + ---@type table? + local doc2 = widgetState.document + local inp = doc2 and doc2:GetElementById("tf-project-search") + widgetState.projectOpenFilter = (inp and inp:GetAttribute("value")) or "" + widgetState.projectOpenNeedsRebuild = true + end, + onProjectSearchClear = function(_event) + playSound("click") + ---@type table? + local doc2 = widgetState.document + local inp = doc2 and doc2:GetElementById("tf-project-search") + if inp then + inp:SetAttribute("value", "") + end + widgetState.projectOpenFilter = "" + widgetState.projectOpenNeedsRebuild = true + end, + onProjectSort = function(_event, mode) + playSound("click") + widgetState.projectOpenSort = mode or "recent" + ---@type table? + local d = widgetState.dmHandle + if d then + d.projectOpenSort = widgetState.projectOpenSort + end + widgetState.projectOpenNeedsRebuild = true + end, -- GENERATE TERRAIN toggle: off (default) creates a dead-flat map; on reveals -- the procedural terrain/water/resources/layout controls and the randomizer. onNewMapGenToggle = function(_event) @@ -7494,6 +8410,10 @@ local initialModel = { widgetState.g3Toast.expiry = 0 end end, + onGuideToggleFocus = function(_event) + widgetState.setFocusMode(not widgetState.focusMode) + playSound("modeSwitch") + end, onGuideTogglePassthrough = function(_event) if not widgetState.passthroughMode then local saved = nil @@ -7929,6 +8849,26 @@ local initialModel = { d.wiggleSpdIdx = i end end, + onGuideTogglePerfMode = function(_event) + widgetState.uiPrefs = widgetState.uiPrefs or {} + local newVal = not widgetState.uiPrefs.perfMode + widgetState.uiPrefs.perfMode = newVal + playSound(newVal and "toggleOn" or "toggleOff") + widgetState.pushPerfPrefs() + if widgetState.saveUiPrefs then + widgetState.saveUiPrefs() + end + end, + onGuideToggleClayStack = function(_event) + widgetState.uiPrefs = widgetState.uiPrefs or {} + local newVal = not widgetState.uiPrefs.clayStack + widgetState.uiPrefs.clayStack = newVal + playSound(newVal and "toggleOn" or "toggleOff") + widgetState.pushPerfPrefs() + if widgetState.saveUiPrefs then + widgetState.saveUiPrefs() + end + end, onGuideToggleDisableTips = function(_event) widgetState.uiPrefs = widgetState.uiPrefs or {} local newVal = not widgetState.uiPrefs.disableTips @@ -8466,8 +9406,19 @@ local initialModel = { if not d then return end - Spring.SetSunDirection(d.sunPos[1], d.sunPos[2], d.sunPos[3]) + local intensity = d.sunIntensity or widgetState.envSunIntensity or 1.0 + Spring.SetSunDirection(d.sunPos[1], d.sunPos[2], d.sunPos[3], intensity) + widgetState.envSunIntensity = intensity Spring.SetSunLighting({ groundShadowDensity = d.groundShadowDensity, modelShadowDensity = d.unitShadowDensity }) + if widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + end + _envSetSlider( + "slider-env-sun-intensity", + "lbl-env-sun-intensity", + math.floor(intensity * 1000 + 0.5), + string.format("%.2f", intensity) + ) _envSetSlider( "slider-env-sun-y", "lbl-env-sun-y", @@ -8883,7 +9834,50 @@ local initialModel = { playSound("save") Spring.Echo("[Environ] Loaded environment config: " .. newest) end, - + -- ENV panel PRESETS (Sun & Shadows window): SAVE writes the live environment + -- under a name, BROWSE lists sun-only quick presets, the harvested map moods + -- and the user's files; the SUN ONLY / FULL chips set what a click applies. + onEnvPresetSave = function(_event) + ---@type table? + local doc = widgetState.document + local inp = doc and doc:GetElementById("env-preset-name-input") + local name = inp and (inp:GetAttribute("value") or "") or "" + local ok, msg = widgetState.saveEnvPreset(name) + ---@type table? + local d = widgetState.dmHandle + if d then + d.envPresetHint = ok and ("Saved " .. tostring(name)) or tostring(msg) + end + if ok then + playSound("save") + if inp then + inp:SetAttribute("value", "") + end + if widgetState.envPresetDropdownOpen and widgetState.rebuildEnvPresetList then + widgetState.rebuildEnvPresetList() + end + end + end, + onEnvPresetToggle = function(_event) + local open = not widgetState.envPresetDropdownOpen + if open and widgetState.rebuildEnvPresetList then + widgetState.rebuildEnvPresetList() + end + if widgetState.setEnvPresetDropdownOpen then + widgetState.setEnvPresetDropdownOpen(open) + end + playSound("click") + end, + onEnvPresetScope = function(_event, scope) + playSound("click") + widgetState.envPresetScope = scope == "sun" and "sun" or "full" + ---@type table? + local d = widgetState.dmHandle + if d then + d.envPresetScope = widgetState.envPresetScope + end + end, + -- ── Terraform mode buttons ──────────────────────────────────────────────── -- data-event-click="onTfSetMode('raise')" onTfSetMode = function(_event, mode) @@ -9585,6 +10579,12 @@ local initialModel = { sp.setCurve(_elemSliderVal("surf-slider-falloff", 5) / 10) elseif key == "spacing" then sp.setSpacing(_elemSliderVal("surf-slider-spacing", 0)) + elseif key == "scatter-pos" then + sp.setScatterPos(_elemSliderVal("surf-slider-scatter-pos", 0) / 100) + elseif key == "scatter-size" then + sp.setScatterSize(_elemSliderVal("surf-slider-scatter-size", 0) / 100) + elseif key == "scatter-str" then + sp.setScatterStr(_elemSliderVal("surf-slider-scatter-str", 0) / 100) elseif key == "fill-scale" then sp.setFillScale(_elemSliderVal("surf-slider-fill-scale", 1400)) elseif key == "fill-seed" then @@ -9643,6 +10643,99 @@ local initialModel = { (sf2.avoidWater or sf2.avoidCliffs or sf2.altMinEnable or sf2.altMaxEnable) and true or false ) end, + -- INFLUENCE (soft altitude / slope bands scaling the stroke): SURFACE edits + -- the armed texture's profile in dev_surface_painter, LAYERS the active + -- channel's in the splat engine. Same three handlers for both submodes. + onSurfInfluence = function(_event, key) + local dm = widgetState.dmHandle + local eng = (dm and dm.surfMode == "hard") and WG.SplatPainter or WG.SurfacePainter + if not (eng and eng.setInfluence and eng.getState) then + return + end + local inf = (eng.getState() or {}).influence or {} + local nv = not inf[key] + playSound(nv and "toggleOn" or "toggleOff") + eng.setInfluence(key, nv) + end, + onSurfInfluenceSlider = function(_event, key) + if uiState.updatingFromCode then + return + end + if uiState.surfStampFrame and (Spring.GetDrawFrame() - uiState.surfStampFrame) < 3 then + return + end + local dm = widgetState.dmHandle + local eng = (dm and dm.surfMode == "hard") and WG.SplatPainter or WG.SurfacePainter + if not (eng and eng.setInfluence) then + return + end + local map = { + ["alt-min"] = { "altMin", 0 }, + ["alt-max"] = { "altMax", 200 }, + ["alt-feather"] = { "altFeatherLo", 40 }, + ["slope-min"] = { "slopeMin", 0 }, + ["slope-max"] = { "slopeMax", 30 }, + ["slope-feather"] = { "slopeFeather", 10 }, + } + local m = map[key] + if not m then + return + end + local v = _elemSliderVal("surf-slider-inf-" .. key, m[2]) + eng.setInfluence(m[1], v) + -- one Feather slider drives both altitude feathers + if m[1] == "altFeatherLo" then + eng.setInfluence("altFeatherHi", v) + end + end, + onSurfInfluenceCopy = function(_event) + local sp = WG.SurfacePainter + if not (sp and sp.copyInfluenceToAll) then + return + end + local n = sp.copyInfluenceToAll() + playSound("click") + Spring.Echo("[Terraform Brush] influence profile copied to " .. tostring(n) .. " texture(s)") + end, + -- SELECTED SLOT tint (GRADING): per-asset albedo tint of the armed variant + -- in the tileset shader (T.setSlotTint, keyed like FLIP). One slider sets + -- one channel; the other two come from the current entry. + onSurfSlotTint = function(_event, ch) + if uiState.updatingFromCode then + return + end + ---@type table? + local T = WG.TilesetTerrain + local asset = widgetState.surfSelectedAsset and widgetState.surfSelectedAsset() + if not (T and T.setSlotTint and asset) then + return + end + local r, g, b = T.getSlotTint(asset) + local doc = widgetState.document + local sl = doc and doc:GetElementById("surf-slider-slotTint" .. tostring(ch)) + local v = sl and tonumber(sl:GetAttribute("value")) + if not v then + return + end + if ch == "R" then + r = v + elseif ch == "G" then + g = v + elseif ch == "B" then + b = v + end + T.setSlotTint(asset, r, g, b) + end, + onSurfSlotTintReset = function(_event) + ---@type table? + local T = WG.TilesetTerrain + local asset = widgetState.surfSelectedAsset and widgetState.surfSelectedAsset() + if not (T and T.setSlotTint and asset) then + return + end + T.setSlotTint(asset, 1, 1, 1) + playSound("reset") + end, -- LAYERS display: the splat engine's channel overlay, colored per override onSurfHardOverlay = function(_event) local sp = WG.SplatPainter @@ -9804,6 +10897,21 @@ local initialModel = { playSound("modeSwitch") WG.SplatPainter.setChannel(tonumber(n) or 1) end, + -- SAMPLE buttons on the SURFACE altitude rows (FILTERS in both modes and the + -- INFLUENCE band): arm the brush widget's height sampler, which reads the + -- next click's ground height (or the colormap contour under the cursor) + -- into the target. 'infAltMin'/'infAltMax' resolve to the engine of the + -- active mode; the FILTERS rows pass their engine's target directly. + onSurfAltSample = function(_event, target) + if not WG.TerraformBrush then + return + end + if target == "infAltMin" or target == "infAltMax" then + target = (widgetState.surfHardActive and "spInf" or "sfInf") .. target:sub(4) + end + local cur = (WG.TerraformBrush.getState() or {}).heightSamplingMode + WG.TerraformBrush.setHeightSamplingMode(cur == target and nil or target) + end, onSurfHardFilter = function(_event, key) if not WG.SplatPainter then return @@ -10105,6 +11213,11 @@ local initialModel = { not (WG.TilesetTerrain.getMetalLights and WG.TilesetTerrain.getMetalLights()) ) playSound(on and "toggleOn" or "toggleOff") + ---@type table? + local dm = widgetState.dmHandle + if dm then + dm.tsGlowOn = on + end local doc = widgetState.document local el = doc and doc:GetElementById("btn-ts-metal-glow") if el then @@ -10114,6 +11227,142 @@ local initialModel = { ) end end, + -- GLOW LIGHT colour swatches, borrowed from the LIGHTS tool: they only write + -- tileset knobs; the shader widget rebuilds the deferred lights from the + -- knob table. + onTsGlowSwatch = function(_event, idx) + local c = widgetState.lpPalette and widgetState.lpPalette[tonumber(idx) or 0] + if not (c and WG.TilesetTerrain and WG.TilesetTerrain.setKnob) then + return + end + WG.TilesetTerrain.setKnob("metalGlowR", c[1]) + WG.TilesetTerrain.setKnob("metalGlowG", c[2]) + WG.TilesetTerrain.setKnob("metalGlowB", c[3]) + playSound("click") + end, + -- HEIGHT TINT (tileset shader 0.27). Axis mode chips, the colour target + -- chips (grade LOW / MID / HIGH, strata beds 1..8, SNOW) and the one shared + -- palette + R/G/B trio that edits whichever chip is selected. Everything + -- writes tileset knobs; tf_tileset.sync paints the chips and restamps the + -- trio from the knob table. The chip -> knob-prefix map comes from + -- tf_tileset (widgetState.tsHgTargets, set in its attach). + onTsHgRefMode = function(_event, n) + if WG.TilesetTerrain and WG.TilesetTerrain.setKnob then + WG.TilesetTerrain.setKnob("hgRefMode", tonumber(n) or 0) + end + playSound("click") + end, + onTsHgTarget = function(_event, t) + local dm = widgetState.dmHandle + if dm then + dm.tsHgTarget = tostring(t) + end + widgetState.tsHgTrioLast = nil -- restamp the trio from the new target + playSound("click") + end, + onTsHgSwatch = function(_event, idx) + local c = widgetState.lpPalette and widgetState.lpPalette[tonumber(idx) or 0] + local set = widgetState.tsHgSet + local dm = widgetState.dmHandle + if not (c and set and dm) then + return + end + -- tf_tileset converts to the chip's own storage (RGB, or HSV for the stops) + if set(dm.tsHgTarget, c[1], c[2], c[3]) then + playSound("click") + end + end, + onTsHgChannel = function(_event, ch) + if uiState.updatingFromCode or not WG.TilesetTerrain then + return + end + -- same deferred-echo guard as onTilesetKnob: a programmatic restamp of + -- the trio raises change events frames later + if uiState.tsStampFrame and (Spring.GetDrawFrame() - uiState.tsStampFrame) < 3 then + return + end + local get, set = widgetState.tsHgGet, widgetState.tsHgSet + local dm = widgetState.dmHandle + if not (get and set and dm) then + return + end + local k = WG.TilesetTerrain.getKnobs and WG.TilesetTerrain.getKnobs() + if not k then + return + end + ch = tostring(ch):lower() + local val = _elemSliderVal("ts-hg-slider-" .. ch, nil) + if val == nil then + return + end + -- one slider moved: rebuild the colour in that slider's space from the + -- chip's current value and write it back through tf_tileset, which + -- converts to the chip's own storage (RGB, or HSV for the stops) + local r, g, b, h, s, v = get(k, dm.tsHgTarget) + if r == nil then + return + end + if ch == "r" or ch == "g" or ch == "b" then + if ch == "r" then + r = val + elseif ch == "g" then + g = val + else + b = val + end + set(dm.tsHgTarget, r, g, b) + else + if ch == "h" then + h = val + elseif ch == "s" then + s = val + else + v = val + end + set(dm.tsHgTarget, nil, nil, nil, h, s, v) + end + end, + onTsStrataMask = function(_event, bit) + local T = WG.TilesetTerrain + if not (T and T.getKnobs and T.setKnob) then + return + end + local k = T.getKnobs() or {} + local m = math.floor((k.strataLayerMask or 0) + 0.5) + bit = tonumber(bit) or 0 + if bit <= 0 then + return + end + local has = (m % (bit * 2)) >= bit + T.setKnob("strataLayerMask", has and (m - bit) or (m + bit)) + playSound(has and "toggleOff" or "toggleOn") + end, + onTsRampMode = function(_event, n) + if WG.TilesetTerrain and WG.TilesetTerrain.setKnob then + WG.TilesetTerrain.setKnob("rampMode", tonumber(n) or 0) + end + playSound("click") + end, + onTsStopsMode = function(_event, n) + if WG.TilesetTerrain and WG.TilesetTerrain.setKnob then + WG.TilesetTerrain.setKnob("stopsMode", tonumber(n) or 1) + end + playSound("click") + end, + onTsRampRescan = function(_event) + if WG.TilesetTerrain and WG.TilesetTerrain.getRamps then + WG.TilesetTerrain.getRamps(true) + end + widgetState.tsRampListSig = nil + playSound("click") + end, + onTsRampClear = function(_event) + if WG.TilesetTerrain and WG.TilesetTerrain.setRamp then + WG.TilesetTerrain.setRamp("") + end + widgetState.tsRampListSig = nil + playSound("toggleOff") + end, onTfSwitchLights = function(_event) playSound("toolSwitch") clearPassthrough() @@ -10407,6 +11656,130 @@ local initialModel = { end playSound(nv and "toggleOn" or "toggleOff") end, + onTbCyclePassability = function(_event) + local TT = WG.TilesetTerrain + if not (TT and TT.setKnob) then + Spring.Echo("[Terraform Brush] PASSABILITY needs the tileset shader (SHADER in the SCENE window)") + return + end + _tbPassIdx = (_tbPassIdx + 1) % (#_tbPassClasses + 1) + local entry = _tbPassClasses[_tbPassIdx] + TT.setKnob("passSlopeDeg", entry and _tbPassDeg(entry) or 0) + local dm = widgetState.dmHandle + if dm then + dm.tbPassActive = entry ~= nil + dm.tbPassLabelStr = entry and ("Pass: " .. entry.key) or "Passability" + end + playSound(entry and "toggleOn" or "toggleOff") + end, + -- ── IMAGE overlay (DISPLAY > Image; chips and window shared by every tool) ── + onTbImageOverlay = function(event) + -- Left click toggles the overlay once an image is loaded; before that, + -- and on right click, it opens the IMAGE OVERLAY window instead. + local p = event and event.parameters + local rightClick = p and p.button == 1 + if rightClick or not _imgOv.toggleShow() then + local dm = widgetState.dmHandle + local open = not (dm and dm.imgOvVisible) + _imgOv.setWindow(open) + playSound(open and "panelOpen" or "click") + end + end, + onImgOvOpen = function(_event) + local dm = widgetState.dmHandle + local open = not (dm and dm.imgOvVisible) + _imgOv.setWindow(open) + playSound(open and "panelOpen" or "click") + end, + onImgOvClose = function(_event) + _imgOv.setWindow(false) + playSound("click") + end, + onImgOvToggleShow = function(_event) + if not _imgOv.toggleShow() then + playSound("toggleOff") + end + end, + onImgOvRefresh = function(_event) + _imgOv.rebuildList(true) + playSound("tick") + end, + onImgOvSlider = function(_event, key) + ---@type table? + local IO = WG.TerraformImageOverlay + if not IO or uiState.updatingFromCode then + return + end + -- Drop the deferred echo of a programmatic restamp (see onTilesetKnob). + if uiState.imgOvStampFrame and (Spring.GetDrawFrame() - uiState.imgOvStampFrame) < 3 then + return + end + for _, row in ipairs(_imgOv.SLIDERS) do + if row[1] == key then + local v = _elemSliderVal("imgov-slider-" .. key, nil) + if v ~= nil then + row[3](v, IO) + local str = tostring(math.floor(v + 0.5)) + widgetState.imgOvLastVal = widgetState.imgOvLastVal or {} + widgetState.imgOvLastVal["imgov-slider-" .. key] = str + _imgOv.setNumbox(key, str) + end + return + end + end + end, + onImgOvFit = function(_event, mode) + ---@type table? + local IO = WG.TerraformImageOverlay + if IO then + IO.setFit(mode) + playSound("tick") + end + end, + onImgOvFlip = function(_event, axis) + ---@type table? + local IO = WG.TerraformImageOverlay + if not IO then + return + end + local s = IO.getState() or {} + if axis == "h" then + IO.setFlip(not s.flipH, nil) + else + IO.setFlip(nil, not s.flipV) + end + playSound("tick") + end, + onImgOvReset = function(_event) + ---@type table? + local IO = WG.TerraformImageOverlay + if IO then + IO.resetPlacement() + _imgOv.stamp(true) + playSound("apply") + end + end, + onImgOvClear = function(_event) + ---@type table? + local IO = WG.TerraformImageOverlay + if IO then + IO.clear() + _imgOv.rebuildList(false) + playSound("toggleOff") + end + end, + onTfFollowStroke = function(_event) + if not WG.TerraformBrush or not WG.TerraformBrush.setFollowStroke then + return + end + local nv = not (WG.TerraformBrush.getState() or {}).followStroke + WG.TerraformBrush.setFollowStroke(nv) + local dm = widgetState.dmHandle + if dm then + dm.tfFollowStroke = nv + end + playSound(nv and "toggleOn" or "toggleOff") + end, onTfPenIntensity = function(_event) if not WG.TerraformBrush then return @@ -11316,6 +12689,68 @@ clearPassthrough = function() end end +-- FOCUS MODE (the eye button next to pause): the engine's /hideinterface with +-- the editor left alive. RmlUi documents are rendered by the engine outside +-- the hidden-interface gate (CGame::Draw calls RmlGui::RenderFrame +-- unconditionally, DrawInputReceivers is the only block hideInterface skips), +-- so the panel survives on its own. The brush widget reads isFocusMode() to +-- keep its ring, grid and water overlays drawing through it, and the deferred +-- applies (skybox picks included) drain from DrawScreenPost because DrawScreen +-- is the one call-in the widget handler gates on Spring.IsGUIHidden(). +-- widgetState field, not a chunk local: this chunk is near the 200-local cap. +widgetState.setFocusMode = function(on) + on = on and true or false + if widgetState.focusMode == on then + return + end + widgetState.focusMode = on + widgetState.focusSetTimer = Spring.GetTimer() + if widgetState.dmHandle then + widgetState.dmHandle.focusActive = on + end + -- Explicit argument, never the bare toggle: the toggle would desync from the + -- flag the moment anything else touched the interface (F5, a map capture). + -- A running capture owns the interface; its restoreScene lands on this flag. + ---@type table? + local cap = WG.TerraformCapture + if not (cap and cap.isBusy and cap.isBusy()) then + Spring.SendCommands(on and "hideinterface 1" or "hideinterface 0") + end +end + +-- Update-side bookkeeping, called once per Update after the panel visibility +-- sync. Two exits besides the button: every tool gone (panel close, quit, tool +-- deactivation) hands the HUD back so nobody is left with no UI at all; and the +-- interface coming back from outside (F5, /hideinterface) drops the flag so the +-- eye reads right and the next click hides again. The T hotkey (panelHidden) is +-- deliberately not an exit: focus + hidden panel is the clean-screenshot setup. +widgetState.syncFocusMode = function(panelVisible, panelHidden) + if not widgetState.focusMode then + return + end + if not panelVisible and not panelHidden then + widgetState.setFocusMode(false) + return + end + ---@type table? + local cap = WG.TerraformCapture + if cap and cap.isBusy and cap.isBusy() then + return + end + -- SendCommands may land a frame late; give a fresh toggle time to take. + -- (Member access, not a local copy: the analyzer types a copied dynamic + -- field as nil and calls the guard impossible.) + if widgetState.focusSetTimer and Spring.DiffTimers(Spring.GetTimer(), widgetState.focusSetTimer) < 0.5 then + return + end + if not Spring.IsGUIHidden() then + widgetState.focusMode = false + if widgetState.dmHandle then + widgetState.dmHandle.focusActive = false + end + end +end + capMinValue = 0 capMaxValue = 0 capEnabled = true -- master on/off for the height cap; min/max values are retained when off @@ -11497,6 +12932,7 @@ local guideHints = { ["btn-ar-start-subtract"] = "Cliff start \xe2\x80\x94 Subtract: the bottom lip stays where it is; the new face carves back into the mesa top.", ["btn-ar-start-average"] = "Cliff start \xe2\x80\x94 Average: the face pivots on the cliff's mid line, biting half into the top and spilling half over the bottom.", ["btn-passthrough"] = "Pause all terraform tools and release keyboard/mouse controls back to the game. Click again or any mode button to resume.", + ["btn-focus"] = "Focus mode: hide the game interface (like F5) but keep the Terraformer alive \xe2\x80\x94 panel, brush preview, overlays and skybox switching all stay on. Click again, close the panel or press F5 to bring the interface back.", ["btn-features"] = "Place decorative props like trees, rocks and crystals using the Feature Placer sub-tool.", ["btn-weather"] = "Spawn persistent weather particle effects such as rain, snow or dust with configurable rate and lifetime.", ["btn-environment"] = "Change the skybox texture at runtime. Select from the skybox library or reset to the map default.", @@ -11520,6 +12956,9 @@ local guideHints = { ["btn-surf-preset-fill"] = "FILL: full strength with a hard edge, for blocking out variant areas fast.", ["btn-surf-erase"] = "Erase mode: strokes withdraw the painted claim so the ground returns to the shader's automatic choice. Right-click always erases. To force plain base instead, pick the BASE tile and paint.", ["surf-slider-spacing"] = "Photoshop-style brush spacing: 0 paints continuously, otherwise one stamp every N elmos of drag distance.", + ["surf-slider-scatter-pos"] = "Scatter position: each stamp is offset by up to this many brush radii in a random direction. With Spacing set, one drag lays a dot field instead of a band.", + ["surf-slider-scatter-size"] = "Scatter size: random size variation per stamp, as a fraction of the brush size.", + ["surf-slider-scatter-str"] = "Scatter strength: random strength variation per stamp, as a fraction of the brush strength.", ["btn-ts-cliff-protect"] = "Keep soft strokes (intermediate, plateau) off cliff bodies and foothills — a big brush sweeps around them instead of eating them. One-way: painting CLIFF forces cliff rock anywhere regardless, and the SURFACE brush never touches hard surfaces either way.", ["ts-slider-exposure"] = "Final gain on the lit ground. The shader takes all its light from the map ENVIRONMENT (sun and ground ambient), never from the skybox, and it draws raw albedo where the engine draws a pre-brightened baked texture — so a dark set on a dimly lit map can go nearly black. This lifts it. Run /tileset probe to see whether the map is actually dark before reaching for it; relighting the environment is the honest fix.", ["ts-slider-lumaTops"] = "Whether the brightness bias above also applies to the soft tops. 0 keeps it off them, so how much ground a top takes is authored rather than decided by which top is paler; 1 is the old behaviour. Expect a slightly wider intermediary at 0, since a pale sand no longer gets a free boost against it.", @@ -12821,7 +14260,7 @@ ctx.syncTBMirrorControls = function(doc, prefix) -- Warn chips on DISPLAY/INSTRUMENTS toggle headers: show when the section -- is collapsed AND at least one mirrored control is engaged. Missing chips -- (tools that never got a warn chip added in RML) silently no-op. - local dispActive = s.gridOverlay or s.heightColormap + local dispActive = s.gridOverlay or s.heightColormap or _imgOv.active() local instActive = s.gridSnap or s.angleSnap or s.measureActive or s.symmetryActive ctx.syncWarnChip(doc, "warn-chip-" .. P .. "-overlays", "section-" .. P .. "-overlays", dispActive) ctx.syncWarnChip(doc, "warn-chip-" .. P .. "-instruments", "section-" .. P .. "-instruments", instActive) @@ -12963,6 +14402,11 @@ local function attachDeclarativeHandlers(_ctx) { "slider-ar-erosion", "ar-erosion" }, { "slider-ar-talus", "ar-talus" }, { "slider-erode-repose", "erode-repose" }, + -- IMAGE OVERLAY window sliders: same pattern, drag ids match _imgOv.stamp. + { "imgov-slider-opacity", "imgov-opacity" }, + { "imgov-slider-offx", "imgov-offx" }, + { "imgov-slider-offy", "imgov-offy" }, + { "imgov-slider-scale", "imgov-scale" }, } for i = 1, #SNAP_SLIDERS do local el = getCachedEl(doc, SNAP_SLIDERS[i][1]) @@ -13531,6 +14975,9 @@ local function attachEventListeners() if dm then dm.tfVelocityIntensity = false end + if dm then + dm.tfFollowStroke = false + end event:StopPropagation() end, false) end @@ -13544,33 +14991,22 @@ local function attachEventListeners() local lastFilter = "" if presetNameInput then - presetNameInput:AddEventListener("focus", function(event) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = presetNameInput - end, false) - presetNameInput:AddEventListener("blur", function(event) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(presetNameInput) end -- Save Project name input (FILE > Save Project): same SDL text-input capture -- as the preset input, plus a change listener mirroring into widgetState so -- the confirm handler has the value even if GetAttribute lags the keystroke. + -- The three search / name fields added later (Open Project filter, Light + -- Library filter and its preset name) shipped without the capture above and + -- could not be typed into at all. + widgetState.wireTextInput(getCachedEl(doc, "tf-project-search")) + widgetState.wireTextInput(getCachedEl(doc, "ll-search-input")) + widgetState.wireTextInput(getCachedEl(doc, "input-ll-preset-name")) + local projectNameInput = getCachedEl(doc, "input-project-name") if projectNameInput then - projectNameInput:AddEventListener("focus", function(event) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = projectNameInput - end, false) - projectNameInput:AddEventListener("blur", function(event) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(projectNameInput) projectNameInput:AddEventListener("change", function(event) widgetState.projectNameStr = projectNameInput:GetAttribute("value") or "" -- Editing the name retargets the save: any armed overwrite confirm @@ -13583,16 +15019,7 @@ local function attachEventListeners() -- game eats every keystroke and the field never types) + change mirror. local newMapNameInput = getCachedEl(doc, "newmap-name-input") if newMapNameInput then - newMapNameInput:AddEventListener("focus", function(event) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = newMapNameInput - end, false) - newMapNameInput:AddEventListener("blur", function(event) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(newMapNameInput) newMapNameInput:AddEventListener("change", function(event) widgetState.newMapNameStr = newMapNameInput:GetAttribute("value") or "" end, false) @@ -13760,20 +15187,92 @@ local function attachEventListeners() -- tileset preset is just a named snapshot of the knob table, stored in the write-dir -- widget via WG.TilesetTerrain.savePreset/loadPreset. Closures hang on widgetState so -- the model handlers (onTilesetPreset*) can drive them. + -- Sun & Shadows PRESETS dropdown: same shape as the tileset one below. The + -- catalog is rebuilt on every open (user files change on disk); a row click + -- applies with the panel's scope; user rows carry an X that deletes the + -- file. In a do-block: this function is near the Lua 5.1 local/upvalue caps. + do + local envPresetNameInput = getCachedEl(doc, "env-preset-name-input") + local envPresetDropdown = getCachedEl(doc, "env-preset-dropdown") + local envPresetToggleBtn = getCachedEl(doc, "btn-env-preset-toggle") + if envPresetNameInput then + widgetState.wireTextInput(envPresetNameInput) + end + widgetState.setEnvPresetDropdownOpen = function(open) + widgetState.envPresetDropdownOpen = open + if envPresetDropdown then + envPresetDropdown:SetClass("hidden", not open) + end + if envPresetToggleBtn then + envPresetToggleBtn:SetClass("open", open) + end + end + widgetState.rebuildEnvPresetList = function() + if not envPresetDropdown then + return + end + envPresetDropdown.inner_rml = "" + local entries = widgetState.listEnvPresets() + local kindLabel = { sun = "sun only", mood = "map mood", user = "saved" } + local lastKind + for _, entry in ipairs(entries) do + if entry.kind ~= lastKind then + lastKind = entry.kind + local head = doc:CreateElement("div") + head:SetClass("tf-preset-summary", true) + head.inner_rml = kindLabel[entry.kind] or entry.kind + envPresetDropdown:AppendChild(head) + end + local row = doc:CreateElement("div") + row:SetClass("tf-preset-row", true) + if widgetState.envPresetCurrent == entry.name then + row:SetClass("selected", true) + end + local topRow = doc:CreateElement("div") + topRow:SetClass("tf-preset-row-top", true) + local nameEl = doc:CreateElement("div") + nameEl:SetClass("tf-preset-name", true) + nameEl.inner_rml = entry.name:gsub("&", "&"):gsub("<", "<") + topRow:AppendChild(nameEl) + if entry.kind == "user" and entry.path then + local delEl = doc:CreateElement("div") + delEl:SetClass("tf-preset-delete", true) + delEl.inner_rml = "X" + delEl:AddEventListener("click", function(event) + playSound("reset") + os.remove(entry.path) + Spring.Echo("[Environ] deleted environment preset: " .. entry.path) + widgetState.rebuildEnvPresetList() + event:StopPropagation() + end, false) + topRow:AppendChild(delEl) + end + row:AppendChild(topRow) + row:AddEventListener("click", function(event) + playSound("click") + local ok = widgetState.applyEnvPreset(entry, widgetState.envPresetScope or "full") + ---@type table? + local d = widgetState.dmHandle + if d then + d.envPresetHint = ok and ("Applied " .. entry.name) + or ("Could not apply " .. entry.name .. " (see console)") + end + if ok and envPresetNameInput then + envPresetNameInput:SetAttribute("value", entry.name) + end + widgetState.setEnvPresetDropdownOpen(false) + event:StopPropagation() + end, false) + envPresetDropdown:AppendChild(row) + end + end + end + local tsPresetNameInput = getCachedEl(doc, "ts-preset-name-input") local tsPresetDropdown = getCachedEl(doc, "ts-preset-dropdown") local tsPresetToggleBtn = getCachedEl(doc, "btn-ts-preset-toggle") if tsPresetNameInput then - tsPresetNameInput:AddEventListener("focus", function(_e) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = tsPresetNameInput - end, false) - tsPresetNameInput:AddEventListener("blur", function(_e) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(tsPresetNameInput) end local function setTsDropdownOpen(open) widgetState.tsDropdownOpen = open @@ -13978,6 +15477,7 @@ local function attachEventListeners() makeWindowDraggable("tf-project-handle", getCachedEl(doc, "tf-project-root")) makeWindowDraggable("tf-project-open-handle", getCachedEl(doc, "tf-project-open-root")) makeWindowDraggable("tf-capture-handle", getCachedEl(doc, "tf-capture-root")) + makeWindowDraggable("tf-imgov-handle", getCachedEl(doc, "tf-imgov-root")) end -- ===== Transport (auto-scroll) button listeners ===== @@ -14032,6 +15532,23 @@ local function editorWantsPanel() return false end +-- Open the editor the way the terraformbrush action does: the brush in RAISE. +-- A fresh editor canvas is only ever started to edit it (requested by PtaQ +-- 2026-09-04), so a New Map opens it from its forcestart below and a project +-- load from cmd_map_project's finishLoad (WG.TerraformBrushUI.openEditor). +-- No-op while any tool already has the panel up, so it never yanks a user off +-- the tool they picked. widgetState field: this chunk is near the local cap. +widgetState.openEditor = function() + if editorWantsPanel() then + return + end + ---@type table? + local tf = WG.TerraformBrush + if tf and tf.setMode then + tf.setMode("raise") + end +end + -- Build the panel document on first use. -- -- The RML is ~6200 elements and ~1800 data bindings, and RmlUi carries that in @@ -14078,11 +15595,13 @@ local function ensureDocument() widgetState.rootElement:SetAttribute("style", buildRootStyle()) -- Pen pressure: suppress brush modulation when cursor is over the UI panel widgetState.rootElement:AddEventListener("mouseover", function() + widgetState.mouseOverPanel = true if WG.TerraformBrush then WG.TerraformBrush.setPenOverUI(true) end end, false) widgetState.rootElement:AddEventListener("mouseout", function() + widgetState.mouseOverPanel = false if WG.TerraformBrush then WG.TerraformBrush.setPenOverUI(false) end @@ -14111,7 +15630,8 @@ function widget:Initialize() -- both mean the keep-alive toggle is already effectively ON. do local allyCount = #Spring.GetAllyTeamList() - 1 -- minus gaia - if Spring.GetModOptions().deathmode == "neverend" or allyCount < 2 then + local mo = Spring.GetModOptions() + if mo.deathmode == "neverend" or tostring(mo.editor_sandbox or "") == "1" or allyCount < 2 then widgetState.keepAlive = { active = true } dm.keepAliveStr = "ON" dm.keepAliveActive = true @@ -14126,6 +15646,17 @@ function widget:Initialize() widgetState._pendingFogOff = 15 end + -- Editor canvases have no commander to place (editor_sandbox=1 makes + -- game_initial_spawn skip it), so pregame has nothing to wait for, and + -- pregame clips every ground ray at the flat canvas height (see finishLoad in + -- cmd_map_project.lua): raise terrain before starting and it turns unclickable. + -- Start the game a few draw frames in. Project loads keep their own + -- forcestart at the end of the load pipeline; the countdown consumer skips + -- while one is running. + if _isGeneratedBlankMap() and Spring.GetGameFrame() <= 0 then + widgetState._pendingForceStart = 15 + end + -- The document itself is deferred to ensureDocument(), called from Update the -- first time a tool engages. Everything below is document-independent and has -- to run at boot: prefs, the panel action, and the pending New Map preset all @@ -14135,6 +15666,7 @@ function widget:Initialize() if loadUiPrefs then loadUiPrefs() end + widgetState.pushPerfPrefs() if WG.TerraformBrush then local up = widgetState.uiPrefs local state = WG.TerraformBrush.getState and WG.TerraformBrush.getState() or nil @@ -14163,6 +15695,11 @@ function widget:Initialize() end return true end, nil, "t") + -- /tf_sunlog toggles a traceback on every sun write (see setSunLog). + widgetHandler:AddAction("tf_sunlog", function() + widgetState.setSunLog(not widgetState._sunLogOrig) + return true + end, nil, "t") -- New Map environment preset: if the last Create wrote a pending preset, resolve -- it from the catalog now and arm a short DrawScreen countdown to apply it once @@ -14193,8 +15730,17 @@ function widget:Initialize() end end else - -- New Map with Default environment selected: blank maps often have no - -- map-defined skybox, so apply the first available library skybox. + -- New Map with Default selected. Default is not "leave the engine + -- lighting alone" - that is the flat 0.5 ambient/diffuse placeholder + -- that makes a fresh map look like the shader is broken. It is the + -- canonical sun, applied on the same countdown a mood would use. + local envDef = widgetState.newMapDefaultEnv() + if envDef then + widgetState._pendingEnvApply = envDef + widgetState._pendingEnvCountdown = 15 + end + -- blank maps often have no map-defined skybox, so apply the first + -- available library skybox local first = widgetState.envSkyboxThumbs and widgetState.envSkyboxThumbs[1] if first and first.path then widgetState._pendingSkyboxPath = first.path @@ -14260,6 +15806,17 @@ function widget:Initialize() isEngaged = function() return widgetState.panelEngaged == true end, + -- FOCUS MODE: the game interface is hidden on purpose and the editor keeps + -- drawing through it. cmd_terraform_brush and the capture widget read this + -- to tell it apart from a plain F5 (see setFocusMode). + isFocusMode = function() + return widgetState.focusMode == true + end, + -- Bring the editor up (brush in RAISE) unless a tool already has the + -- panel; cmd_map_project calls this when a project load completes. + openEditor = function() + widgetState.openEditor() + end, -- Returns the panel pixel bounds in Spring screen coords (Y=0 at bottom). -- Returns nil when the panel is hidden or not yet available. getPanelBounds = function() @@ -14319,66 +15876,6 @@ end local lastUpdateClock = Spring.GetTimer() function widget:DrawScreen() - -- New Map environment preset: apply once, a few frames after a fresh-map reload - -- (gives the water renderer time to come up). Frame-counted rather than gated on - -- a game frame so it works while the editor is paused. - if widgetState._pendingEnvApply then - widgetState._pendingEnvCountdown = (widgetState._pendingEnvCountdown or 0) - 1 - if widgetState._pendingEnvCountdown <= 0 then - local p = widgetState._pendingEnvApply - widgetState._pendingEnvApply = nil - widgetState.applyEnvConfig(p) - Spring.Echo("[Terraform Brush] Applied environment preset: " .. (p.name or "?")) - end - end - - -- Placeholder-fog suppression: disable fog a few frames after (re)load. Separate - -- from the preset apply above so it also fires on a plain luaui reload (no preset). - if widgetState._pendingFogOff then - widgetState._pendingFogOff = widgetState._pendingFogOff - 1 - if widgetState._pendingFogOff <= 0 then - widgetState._pendingFogOff = nil - widgetState.disableFog() - end - end - - -- Deferred skybox apply: RmlUI click fires from Update, so gl.Texture must be - -- done here in DrawScreen. Register the DDS in the GL named-texture cache so - -- Spring.SetSkyBoxTexture (which calls CNamedTextures::GetInfo) can find it. - if widgetState._pendingSkyboxPath then - local rawTex = widgetState._pendingSkyboxPath - local tex = rawTex - widgetState._pendingSkyboxPath = nil - if tex ~= "" then - local bound = nil - local candidates = { - tex, - ":r:" .. tex, - ":l:" .. tex, - "maps/" .. tex, - ":r:maps/" .. tex, - ":l:maps/" .. tex, - } - for _, name in ipairs(candidates) do - if gl.Texture(name) then - gl.Texture(false) - bound = name - break - end - end - if not bound then - Spring.Echo("[Terraform Brush] Skybox bind failed: " .. tex) - else - tex = bound - end - end - if widgetState.envFadeEnabled then - startSkyboxFade(tex, rawTex) - else - applySkyboxNow(tex, rawTex) - end - end - -- NOTE: DDS skybox preloading removed. Spring.SetSkyBoxTexture() loads the -- DDS file directly via the engine; eagerly binding all cubemaps into GL -- exhausted the TexMemPool (512 MB) when many large skyboxes were present, @@ -14843,7 +16340,101 @@ widgetState.drawTsBiomeThumbs = function() gl.Color(1, 1, 1, 1) end +-- Deferred applies that need a draw call-in (gl.Texture) or a frame count after +-- a reload. Drained from DrawScreenPost, NOT DrawScreen: the widget handler +-- skips DrawScreen while the interface is hidden (barwidgets.lua, IsGUIHidden) +-- and FOCUS MODE hides it on purpose, which used to leave a skybox pick parked +-- until the HUD came back and would stall a New Map reload's env preset, +-- fog-off and forcestart the same way. DrawScreenPost runs right after +-- DrawScreen in the same frame, so nothing else moves. +widgetState.drainDeferredApplies = function() + -- New Map environment preset: apply once, a few frames after a fresh-map reload + -- (gives the water renderer time to come up). Frame-counted rather than gated on + -- a game frame so it works while the editor is paused. + if widgetState._pendingEnvApply then + widgetState._pendingEnvCountdown = (widgetState._pendingEnvCountdown or 0) - 1 + if widgetState._pendingEnvCountdown <= 0 then + local p = widgetState._pendingEnvApply + widgetState._pendingEnvApply = nil + widgetState.applyEnvConfig(p) + Spring.Echo("[Terraform Brush] Applied environment preset: " .. ((p and p.name) or "?")) + end + end + + -- Placeholder-fog suppression: disable fog a few frames after (re)load. Separate + -- from the preset apply above so it also fires on a plain luaui reload (no preset). + if widgetState._pendingFogOff then + widgetState._pendingFogOff = widgetState._pendingFogOff - 1 + if widgetState._pendingFogOff <= 0 then + widgetState._pendingFogOff = nil + widgetState.disableFog() + end + end + + -- Leave pregame on editor canvases (armed in Initialize). A project load + -- started from its pointer file owns the forcestart itself. + if widgetState._pendingForceStart then + widgetState._pendingForceStart = widgetState._pendingForceStart - 1 + if widgetState._pendingForceStart <= 0 then + widgetState._pendingForceStart = nil + ---@type table? + local mp = WG.MapProject + local loading = mp and mp.isLoading and mp.isLoading() + if not loading then + if Spring.GetGameFrame() <= 0 then + Spring.Echo( + "[Terraform Brush] starting the editor session: no commander to place, and pregame keeps terrain above the canvas base unclickable" + ) + Spring.SendCommands("forcestart") + end + -- New Map: the canvas is playable now, bring the editor up. + widgetState.openEditor() + end + end + end + + -- Deferred skybox apply: RmlUI click fires from Update, so gl.Texture must be + -- done from a draw call-in. Register the DDS in the GL named-texture cache so + -- Spring.SetSkyBoxTexture (which calls CNamedTextures::GetInfo) can find it. + if widgetState._pendingSkyboxPath then + local rawTex = widgetState._pendingSkyboxPath + local tex = rawTex + widgetState._pendingSkyboxPath = nil + if tex ~= "" then + local bound = nil + local candidates = { + tex, + ":r:" .. tex, + ":l:" .. tex, + "maps/" .. tex, + ":r:maps/" .. tex, + ":l:maps/" .. tex, + } + for _, name in ipairs(candidates) do + if gl.Texture(name) then + gl.Texture(false) + bound = name + break + end + end + if not bound then + Spring.Echo("[Terraform Brush] Skybox bind failed: " .. tex) + else + tex = bound + end + end + if widgetState.envFadeEnabled then + startSkyboxFade(tex, rawTex) + else + applySkyboxNow(tex, rawTex) + end + end +end + function widget:DrawScreenPost() + -- Skybox pick, New Map env preset, fog-off, forcestart (see the definition). + widgetState.drainDeferredApplies() + -- FILE dropdown box, read once for every pass below to skip tiles under it. widgetState.measureFileMenuBox() @@ -15441,6 +17032,8 @@ local HEIGHT_BAND_SLIDERS = { "sp-slider-alt-max", "surf-hard-slider-alt-min", "surf-hard-slider-alt-max", + "surf-slider-inf-alt-min", + "surf-slider-inf-alt-max", } -- Widen those sliders to a padded envelope of the map's real height range, @@ -15488,6 +17081,12 @@ function widget:Update() end end + -- Performance / clay prefs reach the brush widget once it exists (it may + -- load after this panel). + if not widgetState.perfPrefsPushed and WG.TerraformBrush and WG.TerraformBrush.setPerfMode then + widgetState.pushPerfPrefs() + end + -- Keep-match-alive / remove-all-units pump (Settings > General). Both need -- /cheat OBSERVED on: "cheat" TOGGLES, so it is only (re)sent while observed -- off, with a resend gap and an attempt cap (same rule as the project load @@ -15974,6 +17573,7 @@ function widget:Update() -- cmd_terraform_brush checks isEngaged() before tool-switch handling, so a -- dormant Terraformer leaves f/m/g/etc. to the engine's own keybinds. widgetState.panelEngaged = panelVisible and true or false + widgetState.syncFocusMode(panelVisible, widgetState.panelHidden) if widgetState.rootElement then widgetState.rootElement:SetClass("hidden", not panelVisible) end @@ -16146,6 +17746,8 @@ function widget:Update() setDm("envWaterVisible", widgetState.envWaterOpen or false) setDm("envDimensionsVisible", widgetState.envDimensionsOpen or false) setDm("envTilesetVisible", widgetState.envTilesetOpen or false) + -- IMAGE overlay: chip state on every DISPLAY row + the window readouts. + _imgOv.sync(setDm) -- Dimensions window open edge: seed the HEIGHT RANGE sliders with -- the range they are about to change. if widgetState.envDimensionsOpen and not widgetState.envDimWasOpen then @@ -16212,6 +17814,10 @@ function widget:Update() or widgetState.surfActive or widgetState.surfHardActive setDm("tfShapeRowVisible", not hideShape) + setDm( + "tfFollowVisible", + (not hideShape) and tfActive and tfState and _tbFollowModes[tfState.mode] and true or false + ) -- smooth submodes: visible only in smooth/level terraform mode local otherToolActive = fpActive or wbActive @@ -16421,6 +18027,14 @@ function widget:Update() if widgetState.dmHandle.tfShapeRowVisible ~= not hideShape2 then widgetState.dmHandle.tfShapeRowVisible = not hideShape2 end + -- Same predicate as the shape row plus the modes whose drag runs the stroke + -- resampler: this reset block re-opens the shape row every frame, so the + -- FOLLOW chip has to be recomputed alongside it. + local followVis = not hideShape2 and tfActive and tfState and _tbFollowModes[tfState.mode] and true + or false + if widgetState.dmHandle.tfFollowVisible ~= followVis then + widgetState.dmHandle.tfFollowVisible = followVis + end end end @@ -16525,6 +18139,8 @@ function widget:Update() elseif widgetState.surfActive then if tfSurface then tfSurface.sync(doc, ctx, WG.SurfacePainter and WG.SurfacePainter.getState(), setSummary) + -- AUTOMATIC DEPOSIT rows under FILL AND SEED are tileset knobs (ts-* ids) + tfTileset.syncDeposit(doc, ctx) end elseif wbState and wbState.active then -- Weather Brush has no M.sync; drive mirror chips directly here. @@ -16673,8 +18289,9 @@ function widget:Update() "btn-wb-persist-up", }, remove) end - elseif tfActive then + elseif tfActive and not widgetState.mirrorStrided(tfState) then -- ===== Terraform mode: update terraform controls ===== + -- (skipped on strided frames mid-drag, see widgetState.mirrorStrided) local state = tfState local effectiveMaxIntensity = getEffectiveMaxIntensity() @@ -16868,12 +18485,12 @@ function widget:Update() local sliderCapMax = getCachedEl(doc, "slider-cap-max") if sliderCapMax and ds ~= "capmax" then - sliderCapMax:SetAttribute("value", tostring(capMaxValue)) + setAttrValueIfChanged(sliderCapMax, "slider-cap-max", tostring(capMaxValue)) end local sliderCapMin = getCachedEl(doc, "slider-cap-min") if sliderCapMin and ds ~= "capmin" then - sliderCapMin:SetAttribute("value", tostring(capMinValue)) + setAttrValueIfChanged(sliderCapMin, "slider-cap-min", tostring(capMinValue)) end local dm = widgetState.dmHandle if dm then @@ -16893,7 +18510,7 @@ function widget:Update() maxVal = 1 end sliderHistory:SetAttribute("max", tostring(maxVal)) - sliderHistory:SetAttribute("value", tostring(state.undoCount or 0)) + setAttrValueIfChanged(sliderHistory, "slider-history", tostring(state.undoCount or 0)) end local clayImg = getCachedEl(doc, "btn-clay-mode") @@ -16920,7 +18537,11 @@ function widget:Update() end local sliderSnapSizeSync = getCachedEl(doc, "slider-grid-snap-size") if sliderSnapSizeSync and uiState.draggingSlider ~= "tf-grid-snap-size" then - sliderSnapSizeSync:SetAttribute("value", tostring(state.gridSnapSize or 48)) + setAttrValueIfChanged( + sliderSnapSizeSync, + "slider-grid-snap-size", + tostring(state.gridSnapSize or 48) + ) end if widgetState.dmHandle then local v = tostring(state.gridSnapSize or 48) @@ -16930,7 +18551,11 @@ function widget:Update() end local snapSizeNb = getCachedEl(doc, "slider-grid-snap-size-numbox") if snapSizeNb then - snapSizeNb:SetAttribute("value", tostring(state.gridSnapSize or 48)) + setAttrValueIfChanged( + snapSizeNb, + "slider-grid-snap-size-numbox", + tostring(state.gridSnapSize or 48) + ) end -- Protractor state sync @@ -16961,7 +18586,7 @@ function widget:Update() local curStr = (curStep == math.floor(curStep)) and tostring(math.floor(curStep)) or tostring(curStep) local sliderAngleStepSync = getCachedEl(doc, "slider-angle-snap-step") if sliderAngleStepSync and uiState.draggingSlider ~= "tf-angle-snap-step" then - sliderAngleStepSync:SetAttribute("value", tostring(curIdx - 1)) + setAttrValueIfChanged(sliderAngleStepSync, "slider-angle-snap-step", tostring(curIdx - 1)) end if widgetState.dmHandle then if widgetState.dmHandle.tbAngleSnapStepStr ~= curStr then @@ -16970,7 +18595,7 @@ function widget:Update() end local angleStepNb = getCachedEl(doc, "slider-angle-snap-step-numbox") if angleStepNb then - angleStepNb:SetAttribute("value", curStr) + setAttrValueIfChanged(angleStepNb, "slider-angle-snap-step-numbox", curStr) end -- Autosnap toggle + manual spoke sync @@ -17073,7 +18698,11 @@ function widget:Update() end local symCountSlider = getCachedEl(doc, "slider-symmetry-radial-count") if symCountSlider then - symCountSlider:SetAttribute("value", tostring(state.symmetryRadialCount or 2)) + setAttrValueIfChanged( + symCountSlider, + "slider-symmetry-radial-count", + tostring(state.symmetryRadialCount or 2) + ) end if widgetState.dmHandle then local v = tostring(math.floor(state.symmetryMirrorAngle or 0)) @@ -17083,7 +18712,11 @@ function widget:Update() end local mirrorAngleSlider = getCachedEl(doc, "slider-symmetry-mirror-angle") if mirrorAngleSlider then - mirrorAngleSlider:SetAttribute("value", tostring(state.symmetryMirrorAngle or 0)) + setAttrValueIfChanged( + mirrorAngleSlider, + "slider-symmetry-mirror-angle", + tostring(state.symmetryMirrorAngle or 0) + ) end local hasAxial = state.symmetryMirrorX or state.symmetryMirrorY if widgetState.dmHandle then @@ -17144,6 +18777,10 @@ function widget:Update() dm.tfVelocityIntensity = state.velocityIntensity == true end + if dm then + dm.tfFollowStroke = state.followStroke == true + end + do local penEnabled = state.penPressureEnabled == true local pm = state.penPressureMapped or state.penPressure or 0 @@ -17358,7 +18995,7 @@ function widget:Update() local noiseSliderScale = getCachedEl(doc, "slider-noise-scale") if noiseSliderScale and ds ~= "noise-scale" then - noiseSliderScale:SetAttribute("value", tostring(state.noiseScale)) + setAttrValueIfChanged(noiseSliderScale, "slider-noise-scale", tostring(state.noiseScale)) end if dm then local v = tostring(state.noiseScale) @@ -17369,7 +19006,7 @@ function widget:Update() local noiseSliderOctaves = getCachedEl(doc, "slider-noise-octaves") if noiseSliderOctaves and ds ~= "noise-octaves" then - noiseSliderOctaves:SetAttribute("value", tostring(state.noiseOctaves)) + setAttrValueIfChanged(noiseSliderOctaves, "slider-noise-octaves", tostring(state.noiseOctaves)) end if dm then local v = tostring(state.noiseOctaves) @@ -17380,7 +19017,11 @@ function widget:Update() local noiseSliderPersist = getCachedEl(doc, "slider-noise-persistence") if noiseSliderPersist and ds ~= "noise-persistence" then - noiseSliderPersist:SetAttribute("value", tostring(math.floor(state.noisePersistence * 100 + 0.5))) + setAttrValueIfChanged( + noiseSliderPersist, + "slider-noise-persistence", + tostring(math.floor(state.noisePersistence * 100 + 0.5)) + ) end if dm then local v = string.format("%.2f", state.noisePersistence) @@ -17391,7 +19032,11 @@ function widget:Update() local noiseSliderLacun = getCachedEl(doc, "slider-noise-lacunarity") if noiseSliderLacun and ds ~= "noise-lacunarity" then - noiseSliderLacun:SetAttribute("value", tostring(math.floor(state.noiseLacunarity * 10 + 0.5))) + setAttrValueIfChanged( + noiseSliderLacun, + "slider-noise-lacunarity", + tostring(math.floor(state.noiseLacunarity * 10 + 0.5)) + ) end if dm then local v = string.format("%.1f", state.noiseLacunarity) @@ -17402,7 +19047,7 @@ function widget:Update() local noiseSliderSeed = getCachedEl(doc, "slider-noise-seed") if noiseSliderSeed and ds ~= "noise-seed" then - noiseSliderSeed:SetAttribute("value", tostring(state.noiseSeed)) + setAttrValueIfChanged(noiseSliderSeed, "slider-noise-seed", tostring(state.noiseSeed)) end if dm then local v = tostring(state.noiseSeed) @@ -17553,12 +19198,12 @@ function widget:Update() local exportMinInput = doc and getCachedEl(doc, "input-tf-export-min") if exportMinInput and widgetState.focusedRmlInput ~= exportMinInput then local minStr = string.format("%.2f", state.exportCustomMin or 0) - exportMinInput:SetAttribute("value", minStr) + setAttrValueIfChanged(exportMinInput, "input-tf-export-min", minStr) end local exportMaxInput = doc and getCachedEl(doc, "input-tf-export-max") if exportMaxInput and widgetState.focusedRmlInput ~= exportMaxInput then local maxStr = string.format("%.2f", state.exportCustomMax or 0) - exportMaxInput:SetAttribute("value", maxStr) + setAttrValueIfChanged(exportMaxInput, "input-tf-export-max", maxStr) end end -- Slider wheel-lock pulse animation @@ -17955,6 +19600,10 @@ end function widget:Shutdown() WG.TerraformBrushUI = nil + -- Hand the game interface back before anything else: a /luaui reload with + -- focus mode on must not leave the user with no UI at all. + widgetState.setFocusMode(false) + -- The water level preview plane is drawn by the other widget, so a shutdown -- with the Dimensions window open would strand it on screen. if WG.TerraformBrush and WG.TerraformBrush.setWaterLevelPreview then @@ -18100,4 +19749,8 @@ function widget:Shutdown() skyFade.phase = "idle" widgetHandler:RemoveAction("terraformpanel") + widgetHandler:RemoveAction("tf_sunlog") + if widgetState.setSunLog then + widgetState.setSunLog(false) + end end diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss index 2216008e60f..cf60b5452a9 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss @@ -597,6 +597,43 @@ body { border-width: 1dp; } +/* HEIGHT TINT colour chips (TILESET window): the chip IS the colour, painted + from the knob table by tf_tileset.sync; the selected chip is what the shared + palette + R/G/B trio edits. The label sits on a dark pill so it reads on any + colour. */ +.ts-hg-chip { + flex: 1; + height: 18dp; + display: flex; + align-items: center; + justify-content: center; + border: 1dp #33333380; + border-radius: 3dp; + cursor: pointer; + background-color: #808080; +} +.ts-hg-chip:hover { + border-color: #fdc04c80; +} +.ts-hg-chip.active { + border: 2dp #fdc04c; +} +.ts-hg-chip-label { + font-size: 0.75rem; + color: #ffffff; + background-color: #00000070; + padding: 0 4dp; + border-radius: 2dp; +} +/* HEIGHT TINT ramp file list (same row markup as the feature-map browser) */ +.ts-ramp-list { + max-height: 160dp; +} +.tf-hm-row.ts-ramp-current { + background-color: #1d2a3a; + border: 1dp #2ba5eaa0; +} + /* Environment skybox grid */ .env-skybox-grid { display: flex; @@ -1543,6 +1580,25 @@ body { opacity: 0.7; } +/* === Focus Mode (Eye) Button: game HUD hidden, editor alive === */ +.tf-focus-btn.active { + background-color: #0d2c24; + border-color: #40e0c0; +} + +.tf-focus-btn.active:hover { + background-color: #133a30; + border-color: #7cecd6; +} + +.tf-focus-btn.active img { + image-color: #40e0c0; +} + +.tf-focus-btn.active:hover img { + image-color: #7cecd6; +} + /* === Guide Floating Tooltip === */ .tf-guide-floating-tip { position: absolute; @@ -2122,8 +2178,7 @@ body { } /* Open Project rows: clicking one selects it; LOAD and DELETE sit at the - bottom of the dialog and act on the selection. The window keeps the shared - .tf-newmap-window width — no override — so it matches New Map / Save Project. */ + bottom of the dialog and act on the selection. */ .tf-proj-row.selected { background-color: #1f3a4d; border: 1dp #2ba5ea; @@ -2133,6 +2188,100 @@ body { color: #ffffff; } +/* The Open Project window is wider than the shared .tf-newmap-window and its + rows are set larger: project names and the folder tree need the room, and + the base row sizes (kept by the heightmap browser) read too small in a list + that is browsed rather than glanced at. Scoped to .tf-proj-row so the Save + Project list gets the same type. */ +#tf-project-open-root { + width: 440dp; +} + +.tf-proj-row { + padding: 7dp 10dp; +} + +.tf-proj-row .tf-hm-date { + font-size: 1.05rem; + min-width: 92dp; +} + +.tf-proj-row .tf-hm-mapname { + font-size: 1.3rem; +} + +.tf-proj-row .tf-hm-badge { + font-size: 0.95rem; +} + +/* Project tree folders: a disclosure glyph, the folder name and how many + projects sit under it. Children indent one step per depth (the browser caps + folder depth at 4). */ +.tf-proj-folder { + display: flex; + flex-direction: row; + align-items: center; + gap: 8dp; + flex-shrink: 0; + padding: 6dp 10dp; + margin-top: 4dp; + border-radius: 4dp; + cursor: pointer; + color: #d9c28a; + font-size: 1.1rem; + font-weight: bold; + border: 1dp transparent; +} + +.tf-proj-folder:hover { + background-color: #232a38; + border: 1dp #d9c28a60; +} + +.tf-proj-folder-glyph { + width: 14dp; + color: #94a3b8; + font-size: 0.9rem; +} + +.tf-proj-folder-name { + flex: 1; + white-space: nowrap; + overflow: hidden; +} + +.tf-proj-folder-count { + color: #94a3b8; + font-size: 0.9rem; + font-weight: normal; + white-space: nowrap; +} + +.tf-proj-depth-1 { + margin-left: 14dp; +} + +.tf-proj-depth-2 { + margin-left: 28dp; +} + +.tf-proj-depth-3 { + margin-left: 42dp; +} + +.tf-proj-depth-4 { + margin-left: 56dp; +} + +/* A project's folder path, shown after the name while a search filter is on + (the tree is flattened then, so the folder must travel with the row). */ +.tf-proj-path { + color: #94a3b8; + font-size: 0.85rem; + font-weight: normal; + white-space: nowrap; +} + /* === Absolute Height Cap Toggle === */ .tf-abs-toggle { width: 20dp; @@ -2954,6 +3103,14 @@ body { decorator: vertical-gradient(#3a3a46 #1e1e28); box-shadow: 0dp 2dp 3dp 0dp #00000050, inset 0dp 1dp 0dp 0dp #ffffff14; } +/* Chip rows (DISPLAY, INSTRUMENTS) wrap onto a second line instead of + squeezing: five chips in one row crushed their labels onto two lines each. */ +.tf-chip-row { + flex-wrap: wrap; +} +.tf-chip-row .tf-overlay-chip { + flex-shrink: 0; +} .tf-overlay-chip:hover { background-color: #3a3a46; border-color: #5a5a6a; @@ -3025,6 +3182,22 @@ body { border-color: #55556080; color: #9ca3af; } +/* A chip that carries a whole phrase (FOLLOW STROKE) rather than a word. */ +.tf-inline-chip-lg { + padding: 4dp 10dp; + font-size: 0.92rem; +} +.tf-inline-chip.disabled { + color: #4a4a52; + background-color: #202028; + border-color: #2c2c3440; + cursor: default; +} +.tf-inline-chip.disabled:hover { + color: #4a4a52; + background-color: #202028; + border-color: #2c2c3440; +} .tf-inline-chip.active { background-color: #0d2c24; border-color: #40e0c060; @@ -4013,3 +4186,26 @@ margin-top: 3dp; .tf-capture-result-bad .tf-capture-result-head { color: #fdc04c; } + +/* IMAGE overlay (DISPLAY > Image): the gear chip next to the Image chip and the + file list in the IMAGE OVERLAY window. */ +/* The gear is a chip too (same gradient, border and active state as its + neighbours) and stretches to the row's chip height instead of the fixed + 18dp of tf-sm-btn, which sat visibly shorter than the labelled chips. */ +.tf-imgov-cfg { + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + padding: 3dp 6dp; +} +.tf-imgov-cfg.active { + border: 1dp #2ba5ea; +} +.tf-imgov-list { + max-height: 160dp; +} +.tf-hm-row.imgov-current { + background-color: #1d2a3a; + border: 1dp #2ba5eaa0; +} diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml index e1e8c543228..6ca5fe3fc53 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml @@ -17,13 +17,17 @@ BRUSH - 1.13 + 1.14
+
+ + +
@@ -233,13 +237,22 @@ @@ -550,13 +563,22 @@ @@ -890,6 +912,10 @@
{{tfRestoreLabel2Str}}
+
+
FOLLOW STROKE
+
shape rides the drag direction
+
@@ -933,13 +959,22 @@ @@ -2690,13 +2743,22 @@ @@ -3167,13 +3229,22 @@ @@ -3531,13 +3602,22 @@
Alt min
+
SAMPLE
Alt max
+
SAMPLE
@@ -5720,11 +5845,13 @@
Alt min
+
SAMPLE
Alt max
+
SAMPLE
@@ -5732,6 +5859,69 @@ + +
+
+ +
INFLUENCE
+ +
+ +
+
@@ -5791,6 +5981,51 @@
CONFIRM?
+ +
+
+ +
AUTOMATIC DEPOSIT
+
RESET
+
+ +
@@ -5814,7 +6049,32 @@
GRADING
+ +
+
+ +
WORLDSPACE TINT (WIP)
+
RESET
+
+ +
+
@@ -6668,6 +7376,67 @@
+ +
+
+
Image Overlay
+
+
+
+
Drop a PNG, JPG, TGA or DDS into Terraform Brush/Overlays/ in the install folder and pick it below. It is laid over the whole map; alpha in the image is kept, so a transparent sheet of guide lines shows only the lines.
+
AllowDeferredMapRendering is off in springsettings.cfg, so the overlay cannot draw.
+
+
+
Show overlay
+
+
Rescan folder
+
+
+
IMAGE: {{imgOvFileStr}}
+
{{imgOvSizeStr}}
+
+
+
{{imgOvError}}
+ +
+
+
PLACEMENT
+
RESET
+
+
+
Opacity
+ + +
+
+
Offset X
+ + +
+
+
Offset Y
+ + +
+
+
Scale
+ + +
+
Offsets are a share of the map (100% = one map width); scale 100% spans the map. Drags preview live on the ground.
+
+
Stretch
+
Keep aspect
+
Flip H
+
Flip V
+
+
+
+
Unload image
+
+
+
+
@@ -6787,6 +7556,21 @@
RESET
+
+
azimuth
+
+ +
+ +
+
+
elevation
+
+ +
+ +
+
azimuth 0 = north (top of the minimap), 90 = east; elevation is degrees above the horizon. The rows below are the same direction as a vector.
height
@@ -6825,6 +7609,31 @@
+
+
+ +
PRESETS
+
+
+
+
apply
+
SUN ONLY
+
FULL ENVIRONMENT
+
+
+ +
+
Save
+
+
+
Browse
+
+
+ +
Sun-only presets are times of day (direction, intensity, sun colours, shadows). Map moods and your saved files also carry fog colour, sky, water and skybox; SUN ONLY takes just their sun. Save writes the whole live environment to Terraform Brush/Environments/.
+
{{envPresetHint}}
+
+
@@ -8506,6 +9315,14 @@
Log
+ +
+
+
Clay build-up
+
OFF: a clay stroke lays one layer (INTENSITY x 8 elmos) over the surface it started on, so overlapping dabs never stack into rings. ON: every tick stacks another layer while you hold or drag (the old behaviour).
+
+
{{clayStackStr}}
+
@@ -8534,6 +9351,14 @@
+ +
+
+
Performance mode
+
For big maps and slower machines: wider dab spacing where the falloff allows it (soft curves, clay), fewer dabs per tick, coarser FOLLOW STROKE angle steps, panel readouts at a lower rate. Also cheap: pause the game while sculpting and use Focus mode (the eye icon).
+
+
{{perfModeStr}}
+
Disable tips/tool recommendations
@@ -8571,10 +9396,10 @@
NAME
-
letters, digits, - and _
+
letters, digits, spaces, - and _; / for a folder
- +
@@ -8607,9 +9432,19 @@
-
Load a saved project from MapProjects/. This restarts the session onto a blank map of the project's size and replays every section. Unsaved changes on the current map will be lost.
+
Load a saved project from MapProjects/. Subfolders list as a tree, so a git clone of a maps repository can sit inside MapProjects/ and pull straight into this browser. Opening restarts the session onto a blank map of the project's size and replays every section. Unsaved changes on the current map will be lost.
+ +
+
+ +
+
+
RECENT
+
NAME
+
SIZE
+
-
+
Select a project above, then LOAD or DELETE it.
Selected: {{projectOpenSelected}}
diff --git a/luaui/RmlWidgets/gui_terraform_brush/tf_environment.lua b/luaui/RmlWidgets/gui_terraform_brush/tf_environment.lua index 8a58ff8716b..dbdb2900971 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/tf_environment.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/tf_environment.lua @@ -151,56 +151,63 @@ function M.attach(doc, ctx) -- (onEnvResetSkybox / onEnvToggleFade now in initialModel in gui_terraform_brush.lua) -- Environment sub-windows - -- Capture map defaults at startup - local sunX, sunY, sunZ = gl.GetSun("pos") - sunX, sunY, sunZ = sunX or 0, sunY or 1, sunZ or 0 - local gaR, gaG, gaB = gl.GetSun("ambient") - gaR, gaG, gaB = gaR or 0, gaG or 0, gaB or 0 - local gdR, gdG, gdB = gl.GetSun("diffuse") - gdR, gdG, gdB = gdR or 0, gdG or 0, gdB or 0 - local gsR, gsG, gsB = gl.GetSun("specular") - gsR, gsG, gsB = gsR or 0, gsG or 0, gsB or 0 - local uaR, uaG, uaB = gl.GetSun("ambient", "unit") - uaR, uaG, uaB = uaR or 0, uaG or 0, uaB or 0 - local udR, udG, udB = gl.GetSun("diffuse", "unit") - udR, udG, udB = udR or 0, udG or 0, udB or 0 - local usR, usG, usB = gl.GetSun("specular", "unit") - usR, usG, usB = usR or 0, usG or 0, usB or 0 - local fogS = gl.GetAtmosphere("fogStart") - local fogE = gl.GetAtmosphere("fogEnd") - local fR, fG, fB, fA = gl.GetAtmosphere("fogColor") - local scR, scG, scB = gl.GetAtmosphere("sunColor") - local skR, skG, skB = gl.GetAtmosphere("skyColor") - local saX, saY, saZ, saAngle = gl.GetAtmosphere("skyAxisAngle") - local gsd = gl.GetSun("shadowDensity", "ground") or 0 - local usd = gl.GetSun("shadowDensity", "unit") or 0 - - widgetState.envDefaults = { - sunPos = { sunX, sunY, sunZ }, - groundAmbient = { gaR, gaG, gaB }, - groundDiffuse = { gdR, gdG, gdB }, - groundSpecular = { gsR, gsG, gsB }, - unitAmbient = { uaR, uaG, uaB }, - unitDiffuse = { udR, udG, udB }, - unitSpecular = { usR, usG, usB }, - fogStart = fogS, - fogEnd = fogE, - fogColor = { fR, fG, fB, fA }, - sunColor = { scR, scG, scB }, - skyColor = { skR, skG, skB }, - skyAxisAngle = { saX, saY, saZ, saAngle }, - groundShadowDensity = gsd, - unitShadowDensity = usd, - cloudColor = { gl.GetAtmosphere("cloudColor") }, - sunIntensity = 1.0, - waterAbsorb = { gl.GetWaterRendering("absorb") }, - waterBaseColor = { gl.GetWaterRendering("baseColor") }, - waterMinColor = { gl.GetWaterRendering("minColor") }, - waterSurfaceColor = { gl.GetWaterRendering("surfaceColor") }, - waterPlaneColor = { gl.GetWaterRendering("planeColor") }, - waterDiffuseColor = { gl.GetWaterRendering("diffuseColor") }, - waterSpecularColor = { gl.GetWaterRendering("specularColor") }, - } + -- Capture the "defaults" the RESET buttons return to. Captured here at + -- attach, and again by applyEnvConfig after a project or preset apply, so + -- RESET means "what this session loaded", not the flat blank-map lighting + -- the engine happened to hold when the panel first opened. + widgetState.captureEnvDefaults = function() + local sunX, sunY, sunZ = gl.GetSun("pos") + sunX, sunY, sunZ = sunX or 0, sunY or 1, sunZ or 0 + local gaR, gaG, gaB = gl.GetSun("ambient") + gaR, gaG, gaB = gaR or 0, gaG or 0, gaB or 0 + local gdR, gdG, gdB = gl.GetSun("diffuse") + gdR, gdG, gdB = gdR or 0, gdG or 0, gdB or 0 + local gsR, gsG, gsB = gl.GetSun("specular") + gsR, gsG, gsB = gsR or 0, gsG or 0, gsB or 0 + local uaR, uaG, uaB = gl.GetSun("ambient", "unit") + uaR, uaG, uaB = uaR or 0, uaG or 0, uaB or 0 + local udR, udG, udB = gl.GetSun("diffuse", "unit") + udR, udG, udB = udR or 0, udG or 0, udB or 0 + local usR, usG, usB = gl.GetSun("specular", "unit") + usR, usG, usB = usR or 0, usG or 0, usB or 0 + local fogS = gl.GetAtmosphere("fogStart") + local fogE = gl.GetAtmosphere("fogEnd") + local fR, fG, fB, fA = gl.GetAtmosphere("fogColor") + local scR, scG, scB = gl.GetAtmosphere("sunColor") + local skR, skG, skB = gl.GetAtmosphere("skyColor") + local saX, saY, saZ, saAngle = gl.GetAtmosphere("skyAxisAngle") + local gsd = gl.GetSun("shadowDensity", "ground") or 0 + local usd = gl.GetSun("shadowDensity", "unit") or 0 + + widgetState.envDefaults = { + sunPos = { sunX, sunY, sunZ }, + groundAmbient = { gaR, gaG, gaB }, + groundDiffuse = { gdR, gdG, gdB }, + groundSpecular = { gsR, gsG, gsB }, + unitAmbient = { uaR, uaG, uaB }, + unitDiffuse = { udR, udG, udB }, + unitSpecular = { usR, usG, usB }, + fogStart = fogS, + fogEnd = fogE, + fogColor = { fR, fG, fB, fA }, + sunColor = { scR, scG, scB }, + skyColor = { skR, skG, skB }, + skyAxisAngle = { saX, saY, saZ, saAngle }, + groundShadowDensity = gsd, + unitShadowDensity = usd, + cloudColor = { gl.GetAtmosphere("cloudColor") }, + -- The engine has no intensity getter; the UI tracks what it applied. + sunIntensity = widgetState.envSunIntensity or 1.0, + waterAbsorb = { gl.GetWaterRendering("absorb") }, + waterBaseColor = { gl.GetWaterRendering("baseColor") }, + waterMinColor = { gl.GetWaterRendering("minColor") }, + waterSurfaceColor = { gl.GetWaterRendering("surfaceColor") }, + waterPlaneColor = { gl.GetWaterRendering("planeColor") }, + waterDiffuseColor = { gl.GetWaterRendering("diffuseColor") }, + waterSpecularColor = { gl.GetWaterRendering("specularColor") }, + } + end + widgetState.captureEnvDefaults() -- Grab floating window root elements widgetState.envSunRootEl = doc:GetElementById("tf-env-sun-root") @@ -858,7 +865,7 @@ function M.attach(doc, ctx) "img-toggle-overlays", "section-overlays", "warn-chip-overlays", - { "btn-grid-overlay", "btn-height-colormap" }, + { "btn-grid-overlay", "btn-height-colormap", "btn-image-overlay" }, false ) widgetState.warningToggle( @@ -900,6 +907,7 @@ function M.attach(doc, ctx) -- Sun & Shadows collapsible sections (default expanded) envSectionToggle("btn-env-toggle-sundir", "img-env-toggle-sundir", "env-section-sundir", true) envSectionToggle("btn-env-toggle-sunint", "img-env-toggle-sunint", "env-section-sunint", true) + envSectionToggle("btn-env-toggle-sunpre", "img-env-toggle-sunpre", "env-section-sunpre", true) envSectionToggle("btn-env-toggle-shadow", "img-env-toggle-shadow", "env-section-shadow", true) -- Fog & Atmosphere collapsible sections (default collapsed) @@ -965,12 +973,14 @@ function M.attach(doc, ctx) envSectionToggle("btn-toggle-ts-cliffs", "img-toggle-ts-cliffs", "section-ts-cliffs", false) envSectionToggle("btn-toggle-ts-place", "img-toggle-ts-place", "section-ts-place", false) envSectionToggle("btn-toggle-ts-slot4", "img-toggle-ts-slot4", "section-ts-slot4", false) + envSectionToggle("btn-toggle-ts-deposit", "img-toggle-ts-deposit", "section-ts-deposit", false) envSectionToggle("btn-toggle-ts-blend", "img-toggle-ts-blend", "section-ts-blend", false) envSectionToggle("btn-toggle-ts-curv", "img-toggle-ts-curv", "section-ts-curv", false) envSectionToggle("btn-toggle-ts-light", "img-toggle-ts-light", "section-ts-light", false) envSectionToggle("btn-toggle-ts-water", "img-toggle-ts-water", "section-ts-water", false) envSectionToggle("btn-toggle-ts-oldmap", "img-toggle-ts-oldmap", "section-ts-oldmap", false) envSectionToggle("btn-toggle-ts-biome", "img-toggle-ts-biome", "section-ts-biome", false) + envSectionToggle("btn-toggle-ts-htint", "img-toggle-ts-htint", "section-ts-htint", false) envSectionToggle("btn-toggle-ts-tints", "img-toggle-ts-tints", "section-ts-tints", false) envSectionToggle("btn-toggle-ts-debug", "img-toggle-ts-debug", "section-ts-debug", false) envSectionToggle("btn-toggle-ts-presets", "img-toggle-ts-presets", "section-ts-presets", false) @@ -982,6 +992,7 @@ function M.attach(doc, ctx) envSectionToggle("btn-toggle-sf-overlays", "img-toggle-sf-overlays", "section-sf-overlays", false) envSectionToggle("btn-toggle-sf-instruments", "img-toggle-sf-instruments", "section-sf-instruments", false) envSectionToggle("btn-toggle-sf-smart", "img-toggle-sf-smart", "section-sf-smart", false) + envSectionToggle("btn-toggle-sf-influence", "img-toggle-sf-influence", "section-sf-influence", false) envSectionToggle("btn-toggle-surf-brush", "img-toggle-surf-brush", "section-surf-brush", true) envSectionToggle("btn-toggle-surf-fill", "img-toggle-surf-fill", "section-surf-fill", false) envSectionToggle("btn-toggle-surf-sculpt", "img-toggle-surf-sculpt", "section-surf-sculpt", false) @@ -1047,6 +1058,8 @@ function M.attach(doc, ctx) -- non-color sliders with ± buttons "sun-y", "sun-x", + "sun-az", + "sun-el", "sun-z", "sun-intensity", "gshadow", @@ -1138,41 +1151,69 @@ function M.attach(doc, ctx) skyDynamic.sunSliderZ = doc:GetElementById("slider-env-sun-z") skyDynamic.sunLabelZ = doc:GetElementById("lbl-env-sun-z") + -- Every direction write goes through this: keeps the applied intensity (the + -- engine defaults a missing 4th argument to 1.0), re-asserts the shadow + -- densities PER SCOPE (reading "shadowDensity" without a scope returns the + -- ground value, which used to flatten a ground/unit split on every nudge), + -- and restamps the other slider pair. + widgetState.envSetSunDir = function(x, y, z, restamp) + Spring.SetSunDirection(x, y, z, widgetState.envSunIntensity or 1.0) + Spring.SetSunLighting({ + groundShadowDensity = gl.GetSun("shadowDensity", "ground"), + modelShadowDensity = gl.GetSun("shadowDensity", "unit"), + }) + if restamp == "azel" and widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + elseif restamp == "xyz" and widgetState.refreshEnvSunSliders then + widgetState.refreshEnvSunSliders() + end + end envSlider("slider-env-sun-y", "lbl-env-sun-y", function(v) return v / 10000 end, function() return (select(2, gl.GetSun("pos"))) * 10000 end, function(val) - local sx, sy, sz = gl.GetSun("pos") - Spring.SetSunDirection(sx, val, sz) - Spring.SetSunLighting({ - groundShadowDensity = gl.GetSun("shadowDensity"), - modelShadowDensity = gl.GetSun("shadowDensity"), - }) + local sx, _, sz = gl.GetSun("pos") + widgetState.envSetSunDir(sx, val, sz, "azel") end) envSlider("slider-env-sun-x", "lbl-env-sun-x", function(v) return v / 10000 end, function() return (select(1, gl.GetSun("pos"))) * 10000 end, function(val) - local sx, sy, sz = gl.GetSun("pos") - Spring.SetSunDirection(val, sy, sz) - Spring.SetSunLighting({ - groundShadowDensity = gl.GetSun("shadowDensity"), - modelShadowDensity = gl.GetSun("shadowDensity"), - }) + local _, sy, sz = gl.GetSun("pos") + widgetState.envSetSunDir(val, sy, sz, "azel") end) envSlider("slider-env-sun-z", "lbl-env-sun-z", function(v) return v / 10000 end, function() return (select(3, gl.GetSun("pos"))) * 10000 end, function(val) - local sx, sy, sz = gl.GetSun("pos") - Spring.SetSunDirection(sx, sy, val) - Spring.SetSunLighting({ - groundShadowDensity = gl.GetSun("shadowDensity"), - modelShadowDensity = gl.GetSun("shadowDensity"), - }) + local sx, sy = gl.GetSun("pos") + widgetState.envSetSunDir(sx, sy, val, "azel") + end) + -- AZIMUTH / ELEVATION: the artist-facing pair (tenths of a degree on the + -- track). Elevation stays above the horizon; the XYZ rows keep the exact + -- vector for people who think in it. + envSlider("slider-env-sun-az", "lbl-env-sun-az", function(v) + return v / 10 + end, function() + local az = widgetState.azElFromSunDir(gl.GetSun("pos")) + return az * 10 + end, function(val) + local _, el = widgetState.azElFromSunDir(gl.GetSun("pos")) + local x, y, z = widgetState.sunDirFromAzEl(val, el) + widgetState.envSetSunDir(x, y, z, "xyz") + end) + envSlider("slider-env-sun-el", "lbl-env-sun-el", function(v) + return v / 10 + end, function() + local _, el = widgetState.azElFromSunDir(gl.GetSun("pos")) + return el * 10 + end, function(val) + local az = widgetState.azElFromSunDir(gl.GetSun("pos")) + local x, y, z = widgetState.sunDirFromAzEl(az, val) + widgetState.envSetSunDir(x, y, z, "xyz") end) envSlider("slider-env-gshadow", "lbl-env-gshadow", function(v) return v / 1000 @@ -1644,6 +1685,11 @@ function M.attach(doc, ctx) grassCfgWireSlider("slider-gb-mapcolorbase", "lbl-gb-mapcolorbase", "mapColorBase", 1000) grassCfgWireSlider("slider-gb-grassbrightness", "lbl-gb-grassbrightness", "grassBrightness", 1000) + -- Same keyboard capture the panel's other text fields get, or these two + -- cannot be typed into (see widgetState.wireTextInput). + ctx.widgetState.wireTextInput(doc:GetElementById("input-gb-blade-tex")) + ctx.widgetState.wireTextInput(doc:GetElementById("input-gb-colormod-tex")) + local bladeApply = doc:GetElementById("btn-gb-blade-tex-apply") if bladeApply then bladeApply:AddEventListener("mousedown", function(event) diff --git a/luaui/RmlWidgets/gui_terraform_brush/tf_lights.lua b/luaui/RmlWidgets/gui_terraform_brush/tf_lights.lua index 9ce063bcbad..91e01ffde06 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/tf_lights.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/tf_lights.lua @@ -286,6 +286,10 @@ function M.attach(doc, ctx) local globeDragging = false local orientPitchInput = doc:GetElementById("lp-orient-pitch-input") local orientYawInput = doc:GetElementById("lp-orient-yaw-input") + -- Same keyboard capture the panel's other text fields get, or the game eats + -- every keystroke and these cannot be typed into (see widgetState.wireTextInput). + ctx.widgetState.wireTextInput(orientPitchInput) + ctx.widgetState.wireTextInput(orientYawInput) local globeKeyReturn -- resolved lazily on first keydown local function applyGlobeDirection() diff --git a/luaui/RmlWidgets/gui_terraform_brush/tf_surface.lua b/luaui/RmlWidgets/gui_terraform_brush/tf_surface.lua index b5c80756b40..619279a626b 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/tf_surface.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/tf_surface.lua @@ -26,6 +26,9 @@ local SLIDER_IDS = { { "surf-slider-strength", "surf-strength" }, { "surf-slider-falloff", "surf-falloff" }, { "surf-slider-spacing", "surf-spacing" }, + { "surf-slider-scatter-pos", "surf-scatter-pos" }, + { "surf-slider-scatter-size", "surf-scatter-size" }, + { "surf-slider-scatter-str", "surf-scatter-str" }, { "surf-slider-fill-scale", "surf-fill-scale" }, { "surf-slider-fill-seed", "surf-fill-seed" }, { "surf-slider-topsTintR", "surf-topsTintR" }, @@ -44,6 +47,17 @@ local SLIDER_IDS = { { "surf-soft-slider-slope-max", "surf-soft-slope-max" }, { "surf-soft-slider-alt-min", "surf-soft-alt-min" }, { "surf-soft-slider-alt-max", "surf-soft-alt-max" }, + -- SELECTED SLOT tint (GRADING; per-asset, dev_tileset_terrain slotTintN) + { "surf-slider-slotTintR", "surf-slotTintR" }, + { "surf-slider-slotTintG", "surf-slotTintG" }, + { "surf-slider-slotTintB", "surf-slotTintB" }, + -- INFLUENCE sliders (shared by both submodes; handler routes by surfMode) + { "surf-slider-inf-alt-min", "surf-inf-alt-min" }, + { "surf-slider-inf-alt-max", "surf-inf-alt-max" }, + { "surf-slider-inf-alt-feather", "surf-inf-alt-feather" }, + { "surf-slider-inf-slope-min", "surf-inf-slope-min" }, + { "surf-slider-inf-slope-max", "surf-inf-slope-max" }, + { "surf-slider-inf-slope-feather", "surf-inf-slope-feather" }, } -- Group tint knobs mirrored from WG.TilesetTerrain (GRADING section). @@ -426,6 +440,36 @@ local function syncTints(doc, ctx) cache = {} widgetState.surfTintLast = cache end + -- SELECTED SLOT tint: the armed texture's per-asset entry in the tileset + -- widget, restamped only when the asset or a channel changes (and never + -- under the slider being dragged). + do + ---@type table? + local TT = WG.TilesetTerrain + local asset = widgetState.surfSelectedAsset and widgetState.surfSelectedAsset() + if TT and TT.getSlotTint and asset then + local r, g, b = TT.getSlotTint(asset) + local vals = { R = r, G = g, B = b } + for ch, v in pairs(vals) do + local key = "slotTint" .. ch + if ds ~= ("surf-" .. key) then + local id = "surf-slider-" .. key + local sig = asset .. "|" .. string.format("%.4f", v) + if cache[id] ~= sig then + cache[id] = sig + local sl = doc:GetElementById(id) + if sl then + sl:SetAttribute("value", tostring(v)) + end + local nb = doc:GetElementById(id .. "-numbox") + if nb then + nb:SetAttribute("value", string.format("%.2f", v)) + end + end + end + end + end + end for _, k in ipairs(TINT_KNOBS) do local key = k[1] local v = knobs[key] @@ -492,6 +536,16 @@ local function syncHard(doc, ctx, setSummary) setDm("surfHardAvoidCliffs", sf.avoidCliffs == true) setDm("surfHardAltMin", sf.altMinEnable == true) setDm("surfHardAltMax", sf.altMaxEnable == true) + do + -- SAMPLE buttons light while the brush widget's height sampler is armed on their target + local hs = WG.TerraformBrush + and WG.TerraformBrush.getState + and (WG.TerraformBrush.getState() or {}).heightSamplingMode + setDm("surfAltMinSample", hs == "spAltMin") + setDm("surfAltMaxSample", hs == "spAltMax") + setDm("surfInfAltMinSample", hs == "spInfAltMin") + setDm("surfInfAltMaxSample", hs == "spInfAltMax") + end setDm("surfHardExportFmt", string.upper(spState.exportFormat or "png")) setDm("surfHardOverlay", spState.showSplatOverlay == true) -- FILTERS live in a canonical collapsed section now, so they get the @@ -502,6 +556,17 @@ local function syncHard(doc, ctx, setSummary) "section-sf-smart", (sf.avoidWater or sf.avoidCliffs or sf.altMinEnable or sf.altMaxEnable) and true or false ) + -- INFLUENCE chips + the active channel's profile name + local inf = spState.influence or {} + setDm("surfInfAlt", inf.altOn == true) + setDm("surfInfSlope", inf.slopeOn == true) + setDm("surfInfKey", spState.influenceKey or "") + ctx.syncWarnChip( + doc, + "warn-chip-sf-influence", + "section-sf-influence", + (inf.altOn or inf.slopeOn) and true or false + ) -- BRUSH sliders mirror the splat engine in this submode (same slider-unit -- mappings as the legacy panel: strength*100, curve*10). @@ -521,6 +586,12 @@ local function syncHard(doc, ctx, setSummary) ss("surf-hard-slider-slope-max", "surf-hard-slope-max", tostring(sf.slopeMax or 45)) ss("surf-hard-slider-alt-min", "surf-hard-alt-min", tostring(sf.altMin or 0)) ss("surf-hard-slider-alt-max", "surf-hard-alt-max", tostring(sf.altMax or 200)) + ss("surf-slider-inf-alt-min", "surf-inf-alt-min", tostring(math.floor((inf.altMin or 0) + 0.5))) + ss("surf-slider-inf-alt-max", "surf-inf-alt-max", tostring(math.floor((inf.altMax or 200) + 0.5))) + ss("surf-slider-inf-alt-feather", "surf-inf-alt-feather", tostring(math.floor((inf.altFeatherLo or 40) + 0.5))) + ss("surf-slider-inf-slope-min", "surf-inf-slope-min", tostring(math.floor((inf.slopeMin or 0) + 0.5))) + ss("surf-slider-inf-slope-max", "surf-inf-slope-max", tostring(math.floor((inf.slopeMax or 30) + 0.5))) + ss("surf-slider-inf-slope-feather", "surf-inf-slope-feather", tostring(math.floor((inf.slopeFeather or 10) + 0.5))) do local setAttrValueIfChanged = ctx.setAttrValueIfChanged local function nb(id, txt) @@ -530,6 +601,12 @@ local function syncHard(doc, ctx, setSummary) nb("surf-slider-strength-numbox", string.format("%.2f", spState.strength or 0.15)) nb("surf-slider-falloff-numbox", string.format("%.1f", spState.curve or 1.0)) nb("surf-hard-slider-slope-max-numbox", tostring(sf.slopeMax or 45)) + nb("surf-slider-inf-alt-min-numbox", tostring(math.floor((inf.altMin or 0) + 0.5))) + nb("surf-slider-inf-alt-max-numbox", tostring(math.floor((inf.altMax or 200) + 0.5))) + nb("surf-slider-inf-alt-feather-numbox", tostring(math.floor((inf.altFeatherLo or 40) + 0.5))) + nb("surf-slider-inf-slope-min-numbox", tostring(math.floor((inf.slopeMin or 0) + 0.5))) + nb("surf-slider-inf-slope-max-numbox", tostring(math.floor((inf.slopeMax or 30) + 0.5))) + nb("surf-slider-inf-slope-feather-numbox", tostring(math.floor((inf.slopeFeather or 10) + 0.5))) nb("surf-hard-slider-alt-min-numbox", tostring(sf.altMin or 0)) nb("surf-hard-slider-alt-max-numbox", tostring(sf.altMax or 200)) end @@ -672,6 +749,14 @@ function M.sync(doc, ctx, surfState, setSummary) setDm("surfSoftAvoidCliffs", ssf.avoidCliffs == true) setDm("surfSoftAltMin", ssf.altMinEnable == true) setDm("surfSoftAltMax", ssf.altMaxEnable == true) + -- SAMPLE buttons light while the brush widget's height sampler is armed on their target + local hs = WG.TerraformBrush + and WG.TerraformBrush.getState + and (WG.TerraformBrush.getState() or {}).heightSamplingMode + setDm("surfAltMinSample", hs == "sfAltMin") + setDm("surfAltMaxSample", hs == "sfAltMax") + setDm("surfInfAltMinSample", hs == "sfInfAltMin") + setDm("surfInfAltMaxSample", hs == "sfInfAltMax") ctx.syncWarnChip( doc, "warn-chip-sf-smart", @@ -679,6 +764,19 @@ function M.sync(doc, ctx, surfState, setSummary) (ssf.avoidWater or ssf.avoidCliffs or ssf.altMinEnable or ssf.altMaxEnable) and true or false ) end + -- INFLUENCE chips + the armed texture's profile name + do + local inf = surfState.influence or {} + setDm("surfInfAlt", inf.altOn == true) + setDm("surfInfSlope", inf.slopeOn == true) + setDm("surfInfKey", shortAsset(surfState.influenceKey or "base")) + ctx.syncWarnChip( + doc, + "warn-chip-sf-influence", + "section-sf-influence", + (inf.altOn or inf.slopeOn) and true or false + ) + end -- Per-slot chip state, and FILL WITH NOISE stays enabled only while some -- channel is both assigned and switched on. local canFill = false @@ -789,6 +887,9 @@ function M.sync(doc, ctx, surfState, setSummary) ss("surf-slider-strength", "surf-strength", tostring(math.floor((surfState.strength or 0.15) * 100 + 0.5))) ss("surf-slider-falloff", "surf-falloff", tostring(math.floor((surfState.curve or 0.5) * 10 + 0.5))) ss("surf-slider-spacing", "surf-spacing", tostring(surfState.spacing or 0)) + ss("surf-slider-scatter-pos", "surf-scatter-pos", tostring(math.floor((surfState.scatterPos or 0) * 100 + 0.5))) + ss("surf-slider-scatter-size", "surf-scatter-size", tostring(math.floor((surfState.scatterSize or 0) * 100 + 0.5))) + ss("surf-slider-scatter-str", "surf-scatter-str", tostring(math.floor((surfState.scatterStr or 0) * 100 + 0.5))) ss("surf-slider-fill-scale", "surf-fill-scale", tostring(surfState.fillScale or 1400)) ss("surf-slider-fill-seed", "surf-fill-seed", tostring(surfState.fillSeed or 0)) do @@ -796,6 +897,17 @@ function M.sync(doc, ctx, surfState, setSummary) ss("surf-soft-slider-slope-max", "surf-soft-slope-max", tostring(ssf.slopeMax or 45)) ss("surf-soft-slider-alt-min", "surf-soft-alt-min", tostring(ssf.altMin or 0)) ss("surf-soft-slider-alt-max", "surf-soft-alt-max", tostring(ssf.altMax or 200)) + local inf = surfState.influence or {} + ss("surf-slider-inf-alt-min", "surf-inf-alt-min", tostring(math.floor((inf.altMin or 0) + 0.5))) + ss("surf-slider-inf-alt-max", "surf-inf-alt-max", tostring(math.floor((inf.altMax or 200) + 0.5))) + ss("surf-slider-inf-alt-feather", "surf-inf-alt-feather", tostring(math.floor((inf.altFeatherLo or 40) + 0.5))) + ss("surf-slider-inf-slope-min", "surf-inf-slope-min", tostring(math.floor((inf.slopeMin or 0) + 0.5))) + ss("surf-slider-inf-slope-max", "surf-inf-slope-max", tostring(math.floor((inf.slopeMax or 30) + 0.5))) + ss( + "surf-slider-inf-slope-feather", + "surf-inf-slope-feather", + tostring(math.floor((inf.slopeFeather or 10) + 0.5)) + ) end do local setAttrValueIfChanged = ctx.setAttrValueIfChanged @@ -806,12 +918,22 @@ function M.sync(doc, ctx, surfState, setSummary) nb("surf-slider-strength-numbox", string.format("%.2f", surfState.strength or 0.15)) nb("surf-slider-falloff-numbox", string.format("%.1f", surfState.curve or 0.5)) nb("surf-slider-spacing-numbox", (surfState.spacing or 0) > 0 and tostring(surfState.spacing) or "off") + nb("surf-slider-scatter-pos-numbox", string.format("%.2f", surfState.scatterPos or 0)) + nb("surf-slider-scatter-size-numbox", string.format("%.2f", surfState.scatterSize or 0)) + nb("surf-slider-scatter-str-numbox", string.format("%.2f", surfState.scatterStr or 0)) nb("surf-slider-fill-scale-numbox", tostring(surfState.fillScale or 1400)) nb("surf-slider-fill-seed-numbox", tostring(surfState.fillSeed or 0)) local ssf = surfState.smartFilters or {} nb("surf-soft-slider-slope-max-numbox", tostring(ssf.slopeMax or 45)) nb("surf-soft-slider-alt-min-numbox", tostring(ssf.altMin or 0)) nb("surf-soft-slider-alt-max-numbox", tostring(ssf.altMax or 200)) + local inf = surfState.influence or {} + nb("surf-slider-inf-alt-min-numbox", tostring(math.floor((inf.altMin or 0) + 0.5))) + nb("surf-slider-inf-alt-max-numbox", tostring(math.floor((inf.altMax or 200) + 0.5))) + nb("surf-slider-inf-alt-feather-numbox", tostring(math.floor((inf.altFeatherLo or 40) + 0.5))) + nb("surf-slider-inf-slope-min-numbox", tostring(math.floor((inf.slopeMin or 0) + 0.5))) + nb("surf-slider-inf-slope-max-numbox", tostring(math.floor((inf.slopeMax or 30) + 0.5))) + nb("surf-slider-inf-slope-feather-numbox", tostring(math.floor((inf.slopeFeather or 10) + 0.5))) end uiState.updatingFromCode = false -- ONLY when a slider was actually re-stamped (see syncHard's note): this diff --git a/luaui/RmlWidgets/gui_terraform_brush/tf_tileset.lua b/luaui/RmlWidgets/gui_terraform_brush/tf_tileset.lua index e0fdbf70595..cc5614e3592 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/tf_tileset.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/tf_tileset.lua @@ -39,6 +39,13 @@ local KNOBS = { { "intermediateBlend", "%.2f" }, { "intermediateEvidence", "%.2f" }, { "cavityFloor", "%.2f" }, + -- DEPOSIT section (automatic sand slot: lee side + pockets) + { "depositSlot", "%d" }, + { "depositStrength", "%.2f" }, + { "depositLee", "%.2f" }, + { "depositCavity", "%.2f" }, + { "windDirDeg", "%.0f" }, + { "depositSlopeDeg", "%.1f" }, { "intermediateScatter", "%.2f" }, { "intermediateStartDeg", "%.1f" }, { "intermediateFullDeg", "%.1f" }, @@ -121,13 +128,188 @@ local KNOBS = { -- 2 cliff, 3 plateau) and how much it darkens { "metalApronLayer", "%d" }, { "metalApronTone", "%.2f" }, + { "metalApronWidth", "%.2f" }, { "metalTintR", "%.2f" }, { "metalTintG", "%.2f" }, { "metalTintB", "%.2f" }, + -- GLOW LIGHT rows (the LIGHTS tool's point-light controls on every spot's + -- light); the on/off knob is a checkbox, mirrored by hand in M.sync below + { "metalGlowBright", "%.2f" }, + { "metalGlowRadius", "%.0f" }, + { "metalGlowHeight", "%.0f" }, + { "metalGlowR", "%.2f" }, + { "metalGlowG", "%.2f" }, + { "metalGlowB", "%.2f" }, + -- HEIGHT TINT (tileset shader 0.27). The colour scalars (gradeLow*, + -- strataColorN*, snow*) have no rows of their own: the selected chip edits + -- them through the shared ts-hg-slider-r/g/b trio (stampHgEditor below). + { "hgRefMin", "%.0f" }, + { "hgRefSpan", "%.0f" }, + { "hgTiltDeg", "%.1f" }, + { "hgTiltDirDeg", "%.0f" }, + { "hgWobble", "%.0f" }, + { "gradeStrength", "%.2f" }, + { "gradeSplit", "%.2f" }, + { "strataStrength", "%.2f" }, + { "strataCount", "%d" }, + { "strataPeriod", "%.0f" }, + { "strataPhase", "%.2f" }, + { "strataHardness", "%.2f" }, + { "strataWobble", "%.0f" }, + { "strataJitter", "%.2f" }, + { "strataThickJitter", "%.2f" }, + { "stopsStrength", "%.2f" }, + { "stopsCount", "%d" }, + { "rampStrength", "%.2f" }, + { "rampRepeat", "%.1f" }, + { "satLow", "%.2f" }, + { "satHigh", "%.2f" }, + { "tideRings", "%d" }, + { "tidePeriod", "%.1f" }, + { "tideStrength", "%.2f" }, + { "tideWobble", "%.1f" }, + { "snowStrength", "%.2f" }, + { "snowLine", "%.0f" }, + { "snowBand", "%.0f" }, + { "snowSlopeDeg", "%.0f" }, -- debugView is not a slider anymore — it's the DEBUG multi-toggle, mirrored to -- dm.tsDebugView in M.sync below (so it's intentionally omitted from this list). } +-- The AUTOMATIC DEPOSIT rows live in the SURFACE panel (FILL AND SEED), not in +-- the TILESET window, so M.syncDeposit stamps just these keys from the SURFACE +-- sync (M.sync early-outs while the TILESET window is closed). A separate set +-- rather than a flag on the KNOBS rows: the analyzer types every row from the +-- first one, so a tagged row reads as a type mismatch. +local DEPOSIT_KNOBS = { + depositSlot = true, + depositStrength = true, + depositLee = true, + depositCavity = true, + windDirDeg = true, + depositSlopeDeg = true, +} + +-- HEIGHT TINT colour chips -> the three knobs behind them. Grade stops, strata +-- beds and snow are stored as R/G/B; the GRADIENT STOPS are stored as H/S/V +-- (MrBob's struct), so a chip carries its key list and a storage flag, and +-- the shared editor converts either way (hgGet / hgSet below). Shared with the +-- gui handlers through widgetState.tsHgGet / tsHgSet (set in M.attach), the +-- same cross-file bridge tf_lights uses for its palette. +local function hgTarget(hsv, k1, k2, k3) + return { hsv = hsv, keys = { k1, k2, k3 } } +end +local function rgbTarget(prefix) + return hgTarget(false, prefix .. "R", prefix .. "G", prefix .. "B") +end +local HG_TARGETS = { + low = rgbTarget("gradeLow"), + mid = rgbTarget("gradeMid"), + high = rgbTarget("gradeHigh"), + snow = rgbTarget("snow"), +} +for n = 1, 8 do + HG_TARGETS["s" .. n] = rgbTarget("strataColor" .. n) + HG_TARGETS["p" .. n] = hgTarget(true, "stopH" .. n, "stopS" .. n, "stopV" .. n) +end +local HG_TARGET_NAMES = { + low = "GRADE LOW", + mid = "GRADE MID", + high = "GRADE HIGH", + snow = "SNOW", +} +for n = 1, 8 do + HG_TARGET_NAMES["s" .. n] = "BED " .. n + HG_TARGET_NAMES["p" .. n] = "STOP " .. n +end +local HG_CHANNELS = { "R", "G", "B", "H", "S", "V" } + +-- HSV (0..1, hue around the wheel; value may exceed 1) <-> RGB +local function hsvToRgb(h, s, v) + h = (tonumber(h) or 0) % 1.0 + s = math.max(0, math.min(1, tonumber(s) or 0)) + v = math.max(0, tonumber(v) or 1) + local i = math.floor(h * 6) % 6 + local f = h * 6 - math.floor(h * 6) + local p, q, t = v * (1 - s), v * (1 - f * s), v * (1 - (1 - f) * s) + if i == 0 then + return v, t, p + elseif i == 1 then + return q, v, p + elseif i == 2 then + return p, v, t + elseif i == 3 then + return p, q, v + elseif i == 4 then + return t, p, v + end + return v, p, q +end + +local function rgbToHsv(r, g, b) + r, g, b = tonumber(r) or 0, tonumber(g) or 0, tonumber(b) or 0 + local maxc, minc = math.max(r, g, b), math.min(r, g, b) + local d = maxc - minc + local h = 0.0 + if d > 0 then + if maxc == r then + h = ((g - b) / d) % 6 + elseif maxc == g then + h = (b - r) / d + 2 + else + h = (r - g) / d + 4 + end + h = h / 6 + end + local s = (maxc > 0) and (d / maxc) or 0 + return h, s, maxc +end + +-- A chip's colour in both spaces: r, g, b, h, s, v (nil when the knobs are missing). +local function hgGet(knobs, target) + local t = HG_TARGETS[target] + if not (t and knobs) then + return nil + end + local a, b, c = knobs[t.keys[1]], knobs[t.keys[2]], knobs[t.keys[3]] + if a == nil or b == nil or c == nil then + return nil + end + if t.hsv then + local r, g, bb = hsvToRgb(a, b, c) + return r, g, bb, a, b, c + end + local h, s, v = rgbToHsv(a, b, c) + return a, b, c, h, s, v +end + +-- Write a chip's colour from either space: pass r, g, b (h, s, v nil) or +-- h, s, v (r, g, b nil); the target's own storage decides what is converted. +local function hgSet(target, r, g, b, h, s, v) + local t = HG_TARGETS[target] + ---@type table? + local T = WG.TilesetTerrain + if not (t and T and T.setKnob) then + return false + end + local a, bb, c + if t.hsv then + if h == nil then + h, s, v = rgbToHsv(r, g, b) + end + a, bb, c = h, s, v + else + if r == nil then + r, g, b = hsvToRgb(h, s, v) + end + a, bb, c = r, g, b + end + T.setKnob(t.keys[1], a) + T.setKnob(t.keys[2], bb) + T.setKnob(t.keys[3], c) + return true +end + -- Every section under the SHADER switch: grayed out while the switch is off, -- because nothing in them affects an engine-rendered map. local TUNING_FRAMES = { @@ -146,6 +328,7 @@ local TUNING_FRAMES = { "frame-ts-oldmap", "frame-ts-biome", "frame-ts-tints", + "frame-ts-htint", "frame-ts-debug", "frame-ts-presets", } @@ -165,6 +348,8 @@ function M.attach(doc, ctx) ctx.widgetState.ts4PaletteSig = nil ctx.widgetState.ts4PaletteEls = nil ctx.widgetState.ts4SectionEl = nil + -- glow colour preview bar: repaint from the knobs on a fresh document + ctx.widgetState.tsGlowPrevLast = nil -- same for the METAL SPOTS suite toggle's gray-out -- Slider drag tracking only. Section collapse for the ts-* frames is wired -- centrally in tf_environment.lua (envSectionToggle), like every other tool. @@ -174,6 +359,20 @@ function M.attach(doc, ctx) trackSliderDrag(el, "ts-" .. k[1]) end end + -- HEIGHT TINT: the shared colour trio tracks drags like the knob rows; the + -- chip / trio / ramp-list caches restart on a fresh document + ctx.widgetState.tsHgTargets = HG_TARGETS + ctx.widgetState.tsHgGet = hgGet + ctx.widgetState.tsHgSet = hgSet + ctx.widgetState.tsHgChipLast = {} + ctx.widgetState.tsHgTrioLast = nil + ctx.widgetState.tsRampListSig = nil + for _, ch in ipairs({ "r", "g", "b", "h", "s", "v" }) do + local el = doc:GetElementById("ts-hg-slider-" .. ch) + if el and trackSliderDrag then + trackSliderDrag(el, "ts-hg-" .. ch) + end + end end -- EXTRA LAYER material picker: one tile per catalog entry, thumb rects left @@ -290,6 +489,243 @@ local function rebuildBiomePalette(doc, ctx, rows, activeKey) end end +-- Push the knob values into the ts-slider-* rows (slider + numbox), skipping +-- the slider being dragged. `only` = nil for every row, or a key set (see +-- DEPOSIT_KNOBS) to stamp just those rows. +local function stampKnobRows(doc, ctx, knobs, only) + local widgetState = ctx.widgetState + local uiState = ctx.uiState + local cache = widgetState.tsLastVal + local ds = uiState.draggingSlider + uiState.updatingFromCode = true + local stamped = false + for _, k in ipairs(KNOBS) do + local key = k[1] + local v = knobs[key] + -- Skip the slider the user is dragging so we don't fight the drag. + if v ~= nil and ds ~= ("ts-" .. key) and (only == nil or only[key]) then + local id = "ts-slider-" .. key + local slStr = tostring(v) + if cache[id] ~= slStr then + cache[id] = slStr + local sl = doc:GetElementById(id) + if sl then + sl:SetAttribute("value", slStr) + stamped = true + end + local nb = doc:GetElementById(id .. "-numbox") + if nb then + nb:SetAttribute("value", string.format(k[2], v)) + end + end + end + end + uiState.updatingFromCode = false + -- RmlUi delivers the change events these SetAttribute stamps raise on a + -- LATER frame, when updatingFromCode is already false. onTilesetKnob uses + -- this timestamp to drop that deferred echo — otherwise every programmatic + -- restamp (biome swap seeds ~a dozen knobs) reads back clamped/stale slider + -- values into the knob table, compounding per swap (the "red intermediate area + -- grows with every Teizer<->Enborelde swap until it pins" ratchet). + if stamped then + uiState.tsStampFrame = Spring.GetDrawFrame() + end +end + +-- HEIGHT TINT editor sync: paint every colour chip from its knobs, restamp the +-- shared R/G/B trio from the SELECTED chip's knobs (skipping a dragged +-- channel, with the same deferred-echo guard stampKnobRows uses), mirror the +-- chip / mode state into the data model, and keep the RAMP file list honest. +local function cssColor(r, g, b) + local function ch(v) + return math.floor(math.max(0, math.min(1, v or 1)) * 255 + 0.5) + end + return string.format("background-color: #%02x%02x%02x;", ch(r), ch(g), ch(b)) +end + +local function rebuildRampList(doc, ctx, T) + local widgetState = ctx.widgetState + local listEl = doc:GetElementById("ts-ramp-list") + if not (listEl and T.getRamps and T.getRamp) then + return + end + local files = T.getRamps(false) or {} + local current = T.getRamp() or "" + local sig = current .. "|" .. table.concat(files, "|") + if widgetState.tsRampListSig == sig then + return + end + widgetState.tsRampListSig = sig + listEl.inner_rml = "" + if #files == 0 then + listEl.inner_rml = '
No images in ' + .. tostring(T.getRampDir and T.getRampDir() or "Terraform Brush/Ramps/") + .. " yet. Drop a gradient PNG in and hit Rescan folder.
" + return + end + for _, name in ipairs(files) do + local item = doc:CreateElement("div") + item:SetClass("tf-hm-row", true) + if name == current then + item:SetClass("ts-ramp-current", true) + end + local safe = tostring(name):gsub("&", "&"):gsub("<", "<"):gsub(">", ">") + item.inner_rml = '
' .. safe .. "
" + item:AddEventListener("click", function(ev) + ---@type table? + local api = WG.TilesetTerrain + if api and api.setRamp then + api.setRamp(name) + -- a pick with the ramp off would show nothing: switch it on + local k = api.getKnobs and api.getKnobs() + if k and (k.rampMode or 0) == 0 and api.setKnob then + api.setKnob("rampMode", 1) + end + if ctx.playSound then + ctx.playSound("apply") + end + end + widgetState.tsRampListSig = nil + ev:StopPropagation() + end, false) + listEl:AppendChild(item) + end +end + +local function syncHeightTint(doc, ctx, knobs, dm) + local widgetState = ctx.widgetState + local uiState = ctx.uiState + if knobs.hgRefMode == nil then + return + end + local ref = math.floor((knobs.hgRefMode or 0) + 0.5) + if dm.tsHgRef ~= ref then + dm.tsHgRef = ref + end + local cnt = math.floor((knobs.strataCount or 4) + 0.5) + if dm.tsStrataCount ~= cnt then + dm.tsStrataCount = cnt + end + local m = math.floor((knobs.strataLayerMask or 0) + 0.5) + local bits = { + tsStrataBase = (m % 2) >= 1, + tsStrataInter = (m % 4) >= 2, + tsStrataCliff = (m % 8) >= 4, + tsStrataPlat = (m % 16) >= 8, + } + for k, v in pairs(bits) do + if dm[k] ~= v then + dm[k] = v + end + end + local rm = math.floor((knobs.rampMode or 0) + 0.5) + if dm.tsRampMode ~= rm then + dm.tsRampMode = rm + end + local sc = math.floor((knobs.stopsCount or 3) + 0.5) + if dm.tsStopsCount ~= sc then + dm.tsStopsCount = sc + end + local sm = math.floor((knobs.stopsMode or 1) + 0.5) + if dm.tsStopsMode ~= sm then + dm.tsStopsMode = sm + end + local target = dm.tsHgTarget + if not HG_TARGETS[target] then + target = "low" + dm.tsHgTarget = target + end + local tname = HG_TARGET_NAMES[target] or target + if dm.tsHgTargetName ~= tname then + dm.tsHgTargetName = tname + end + + -- chips + local chipCache = widgetState.tsHgChipLast + if not chipCache then + chipCache = {} + widgetState.tsHgChipLast = chipCache + end + for t in pairs(HG_TARGETS) do + local cr, cg, cb = hgGet(knobs, t) + local css = cr and cssColor(cr, cg, cb) or nil + if css and chipCache[t] ~= css then + chipCache[t] = css + local el = doc:GetElementById("ts-hg-chip-" .. t) + if el then + el:SetAttribute("style", css) + end + end + end + + -- shared editor (R/G/B + H/S/V sliders) + preview bar from the selected chip + local r, g, b, h, s, v = hgGet(knobs, target) + if r and g and b then + local css = cssColor(r, g, b) + if widgetState.tsHgPrevLast ~= css then + widgetState.tsHgPrevLast = css + local el = doc:GetElementById("ts-hg-preview") + if el then + el:SetAttribute("style", css) + end + end + local sig = + table.concat({ target, tostring(r), tostring(g), tostring(b), tostring(h), tostring(s), tostring(v) }, "|") + if widgetState.tsHgTrioLast ~= sig then + widgetState.tsHgTrioLast = sig + local vals = { R = r, G = g, B = b, H = h, S = s, V = v } + local ds = uiState.draggingSlider + uiState.updatingFromCode = true + local stamped = false + for _, C in ipairs(HG_CHANNELS) do + local c = C:lower() + if ds ~= ("ts-hg-" .. c) then + local sl = doc:GetElementById("ts-hg-slider-" .. c) + if sl then + sl:SetAttribute("value", tostring(vals[C])) + stamped = true + end + local nb = doc:GetElementById("ts-hg-slider-" .. c .. "-numbox") + if nb then + nb:SetAttribute("value", string.format((C == "H") and "%.3f" or "%.2f", vals[C])) + end + end + end + uiState.updatingFromCode = false + if stamped then + uiState.tsStampFrame = Spring.GetDrawFrame() + end + end + end + + -- ramp file label + list + ---@type table? + local T = WG.TilesetTerrain + if T and T.getRamp then + local cur, err = T.getRamp() + local label = (cur and cur ~= "") and cur or "none" + if err and err ~= "" then + label = label .. " (" .. err .. ")" + end + if dm.tsRampFile ~= label then + dm.tsRampFile = label + end + rebuildRampList(doc, ctx, T) + end +end + +-- SURFACE sync hook: the AUTOMATIC DEPOSIT rows (FILL AND SEED) are tileset +-- knobs by id, so keep them honest while the TILESET window is closed. +function M.syncDeposit(doc, ctx) + if not doc or not WG.TilesetTerrain or not ctx.widgetState.tsLastVal then + return + end + local knobs = WG.TilesetTerrain.getKnobs and WG.TilesetTerrain.getKnobs() + if knobs then + stampKnobRows(doc, ctx, knobs, DEPOSIT_KNOBS) + end +end + function M.sync(doc, ctx, setSummary) if not doc or not WG.TilesetTerrain then return @@ -434,6 +870,9 @@ function M.sync(doc, ctx, setSummary) end if WG.TilesetTerrain.getMetalLights then local glow = WG.TilesetTerrain.getMetalLights() and true or false + if dm.tsGlowOn ~= glow then + dm.tsGlowOn = glow -- grays the GLOW LIGHT block while the light is off + end if widgetState.tsGlowLast ~= glow then widgetState.tsGlowLast = glow local el = doc:GetElementById("btn-ts-metal-glow") @@ -452,42 +891,32 @@ function M.sync(doc, ctx, setSummary) return end - local uiState = ctx.uiState - local cache = widgetState.tsLastVal - local ds = uiState.draggingSlider - uiState.updatingFromCode = true - local stamped = false - for _, k in ipairs(KNOBS) do - local key = k[1] - local v = knobs[key] - -- Skip the slider the user is dragging so we don't fight the drag. - if v ~= nil and ds ~= ("ts-" .. key) then - local id = "ts-slider-" .. key - local slStr = tostring(v) - if cache[id] ~= slStr then - cache[id] = slStr - local sl = doc:GetElementById(id) - if sl then - sl:SetAttribute("value", slStr) - stamped = true - end - local nb = doc:GetElementById(id .. "-numbox") - if nb then - nb:SetAttribute("value", string.format(k[2], v)) - end + stampKnobRows(doc, ctx, knobs, nil) + + -- GLOW LIGHT: the colour knobs paint the preview bar (borrowed from the + -- LIGHTS tool). Knob-driven, so a style swap, a section RESET or a project + -- load land here too. + if knobs.metalGlowR and knobs.metalGlowG and knobs.metalGlowB then + local function ch(v) + return math.floor(math.max(0, math.min(1, v)) * 255 + 0.5) + end + local css = string.format( + "background-color: #%02x%02x%02x;", + ch(knobs.metalGlowR), + ch(knobs.metalGlowG), + ch(knobs.metalGlowB) + ) + if widgetState.tsGlowPrevLast ~= css then + widgetState.tsGlowPrevLast = css + local el = doc:GetElementById("ts-glow-preview") + if el then + el:SetAttribute("style", css) end end end - uiState.updatingFromCode = false - -- RmlUi delivers the change events these SetAttribute stamps raise on a - -- LATER frame, when updatingFromCode is already false. onTilesetKnob uses - -- this timestamp to drop that deferred echo — otherwise every programmatic - -- restamp (biome swap seeds ~a dozen knobs) reads back clamped/stale slider - -- values into the knob table, compounding per swap (the "red intermediate area - -- grows with every Teizer<->Enborelde swap until it pins" ratchet). - if stamped then - uiState.tsStampFrame = Spring.GetDrawFrame() - end + + -- HEIGHT TINT chips, shared colour trio, mode chips and the ramp list + syncHeightTint(doc, ctx, knobs, dm) -- Decouple-albedo checkbox: a knob, but rendered as a checkbox rather than a -- 0/1 slider, so mirror it by hand (covers startup + console /tileset changes). diff --git a/luaui/Shaders/terraform_image_overlay.frag.glsl b/luaui/Shaders/terraform_image_overlay.frag.glsl new file mode 100644 index 00000000000..1f3c5879690 --- /dev/null +++ b/luaui/Shaders/terraform_image_overlay.frag.glsl @@ -0,0 +1,51 @@ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +// Terraform Brush IMAGE overlay: projects a user image onto the terrain through +// the map gbuffer depth (same reconstruction as infolos_view / fog_diaglines), +// so it hugs the ground at any map size for one fullscreen pass. Units and +// features draw after this pass and cover it; the sky (depth 1) is discarded. + +uniform sampler2D mapDepths; // $map_gbuffer_zvaltex +uniform sampler2D overlayTex; // the user image + +// x = opacity, y = scale (1 = image spans the map), z/w = offset in map fractions +uniform vec4 params1 = vec4(1.0, 1.0, 0.0, 0.0); +// xy = aspect-fit divisors (1,1 = stretch), z = flip H, w = flip V +uniform vec4 params2 = vec4(1.0, 1.0, 0.0, 0.0); + +//__DEFINES__ +//__ENGINEUNIFORMBUFFERDEFS__ + +in DataVS { + vec2 screenUV; +}; + +out vec4 fragColor; + +void main(void) { + float mapdepth = texture(mapDepths, screenUV).x; + if (mapdepth >= 0.999999) discard; // sky / nothing drawn + + vec4 worldPos = vec4(vec3(screenUV * 2.0 - 1.0, mapdepth), 1.0); + worldPos = cameraViewProjInv * worldPos; + worldPos.xyz /= worldPos.w; + + // 0..1 across the playable map; the engine's out-of-map extension lands + // outside this range and is discarded below. + vec2 mapUV = worldPos.xz / mapSize.xy; + + // Centre-relative placement: shift, then scale, then aspect fit. + vec2 p = (mapUV - 0.5 - params1.zw) / max(params1.y, 1e-4); + p /= params2.xy; + vec2 uv = p + 0.5; + + if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) discard; + + if (params2.z > 0.5) uv.x = 1.0 - uv.x; + if (params2.w > 0.5) uv.y = 1.0 - uv.y; + + vec4 img = texture(overlayTex, uv); + fragColor = vec4(img.rgb, img.a * params1.x); +} diff --git a/luaui/Shaders/terraform_image_overlay.vert.glsl b/luaui/Shaders/terraform_image_overlay.vert.glsl new file mode 100644 index 00000000000..9ee5e55239c --- /dev/null +++ b/luaui/Shaders/terraform_image_overlay.vert.glsl @@ -0,0 +1,18 @@ +#version 430 + +//__DEFINES__ + +//__ENGINEUNIFORMBUFFERDEFS__ + +// Fullscreen tex-rect from InstanceVBOTable.MakeTexRectVAO(): +// xy in [-1,1] (NDC), zw in [0,1] (screen UV). +layout (location = 0) in vec4 position; + +out DataVS { + vec2 screenUV; +}; + +void main(void) { + screenUV = position.zw; + gl_Position = vec4(position.xy, 0.0, 1.0); +} diff --git a/luaui/Widgets/cmd_map_project.lua b/luaui/Widgets/cmd_map_project.lua index c5b13a65e2d..0d66c967770 100644 --- a/luaui/Widgets/cmd_map_project.lua +++ b/luaui/Widgets/cmd_map_project.lua @@ -33,6 +33,10 @@ end -- matches the recorded map (blank-map name, exact size, map damage enabled, -- local singleplayer); on mismatch it deletes the pointer and explains itself. +-- Engine globals as chunk locals: the CI analyzer counts every bare engine +-- global as an undefined-global finding (same table objects, no behaviour change). +local Spring = Spring +local VFS = VFS local Echo = Spring.Echo local PROJECTS_DIR = "MapProjects/" @@ -106,18 +110,48 @@ local RESERVED_NAMES = { lpt9 = true, } +-- A project name may carry folders ("campaign/cm09", "maps-repo/teizer/duel"): +-- each segment follows the single-name rules, the depth is capped and the whole +-- path stays short. Folders are what let a git clone of a maps repository sit +-- inside MapProjects/ and list as a tree in the Open Project dialog. Returns +-- the normalized slug (forward slashes, no leading or trailing separator); +-- callers must use the returned value, not their argument. +local MAX_SLUG_DEPTH = 4 local function validateSlug(slug) if type(slug) ~= "string" or slug == "" then return nil, "missing project name" end - if #slug > 64 then - return nil, "project name too long (max 64)" + slug = slug:gsub("\\", "/"):gsub("^/+", ""):gsub("/+$", "") + if slug == "" then + return nil, "missing project name" end - if not slug:match("^[A-Za-z0-9_%-]+$") then - return nil, "project name may only contain letters, digits, _ and - (no spaces)" + if #slug > 128 then + return nil, "project path too long (max 128)" end - if RESERVED_NAMES[slug:lower()] then - return nil, "'" .. slug .. "' is a reserved Windows device name" + if slug:find("//", 1, true) then + return nil, "project path has an empty folder segment" + end + local depth = 0 + for seg in slug:gmatch("[^/]+") do + depth = depth + 1 + if #seg > 64 then + return nil, "project name segment too long (max 64)" + end + -- Spaces are allowed inside a segment (a git clone of a maps repository + -- keeps its folder names), never at either end: Windows strips trailing + -- spaces from folder names, so such a slug would never round-trip. + if not seg:match("^[A-Za-z0-9_%- ]+$") then + return nil, "project names may only contain letters, digits, spaces, _ and -; / separates folders" + end + if seg:sub(1, 1) == " " or seg:sub(-1) == " " then + return nil, "a folder or project name cannot start or end with a space" + end + if rawget(RESERVED_NAMES, seg:lower()) then + return nil, "'" .. seg .. "' is a reserved Windows device name" + end + end + if depth > MAX_SLUG_DEPTH then + return nil, "project path too deep (max " .. MAX_SLUG_DEPTH .. " levels)" end return slug end @@ -183,6 +217,57 @@ local function readPrevManifest(dir) return data end +-- Recently opened or saved projects, newest first: written by raw io to the +-- write dir, read back by the Open Project list. Two jobs: RECENT ordering by +-- last touch rather than last save, and a second discovery path for folders +-- the VFS snapshot cannot see yet (a project saved this session, a fresh git +-- clone): a manifest raw io can read gets listed even when VFS.SubDirs misses +-- its folder. +local RECENT_PATH = "Terraform Brush/recent_projects.lua" +local RECENT_MAX = 40 + +local function readRecent() + local f = io.open(RECENT_PATH, "rb") + if not f then + return {} + end + local raw = f:read("*a") + f:close() + raw = raw:gsub("^\239\187\191", "") + local chunk = loadstring(raw) + if not chunk then + return {} + end + local ok, data = pcall(chunk) + if not ok or type(data) ~= "table" then + return {} + end + local out = {} + for _, e in ipairs(data) do + local slug = type(e) == "table" and validateSlug(e.slug) or nil + if slug then + out[#out + 1] = { slug = slug, at = tostring(e.at or "") } + end + end + return out +end + +local function touchRecent(slug) + local kept = { { slug = slug, at = isoNow() } } + for _, e in ipairs(readRecent()) do + if e.slug ~= slug and #kept < RECENT_MAX then + kept[#kept + 1] = e + end + end + local parts = { "-- Recently opened or saved map projects, newest first (Terraform Brush).", "return {" } + for _, e in ipairs(kept) do + parts[#parts + 1] = string.format("\t{ slug = %q, at = %q },", e.slug, e.at) + end + parts[#parts + 1] = "}" + Spring.CreateDir("Terraform Brush") + writeFile(RECENT_PATH, table.concat(parts, "\n") .. "\n") +end + -- Generic `return {...}` section file reader (raw io, same VFS-staleness rule). local function readLuaFile(path) local f = io.open(path, "rb") @@ -447,6 +532,36 @@ local function stepSurface() for i = 1, MAX_SURFACE_SLOTS do lines[#lines + 1] = string.format("\tslot%d = %q,", i, tostring(meta["slot" .. i] or "")) end + -- INFLUENCE profiles (soft altitude / slope bands the painter remembers per + -- texture), keyed by asset name, sorted for a stable file. + if type(meta.influence) == "table" and next(meta.influence) then + local names = {} + for n, p in pairs(meta.influence) do + if type(n) == "string" and type(p) == "table" then + names[#names + 1] = n + end + end + table.sort(names) + lines[#lines + 1] = "\tinfluence = {" + for _, n in ipairs(names) do + local p = meta.influence[n] + lines[#lines + 1] = string.format( + "\t\t[%q] = { altOn = %s, altMin = %s, altMax = %s, altFeatherLo = %s, altFeatherHi = %s, " + .. "slopeOn = %s, slopeMin = %s, slopeMax = %s, slopeFeather = %s },", + n, + tostring(p.altOn and true or false), + fmtNum(tonumber(p.altMin) or 0), + fmtNum(tonumber(p.altMax) or 0), + fmtNum(tonumber(p.altFeatherLo) or 0), + fmtNum(tonumber(p.altFeatherHi) or 0), + tostring(p.slopeOn and true or false), + fmtNum(tonumber(p.slopeMin) or 0), + fmtNum(tonumber(p.slopeMax) or 0), + fmtNum(tonumber(p.slopeFeather) or 0) + ) + end + lines[#lines + 1] = "\t}," + end lines[#lines + 1] = "}" lines[#lines + 1] = "" @@ -501,6 +616,39 @@ local function stepTileset() lines[#lines + 1] = string.format("\tslot4_material = %q,", tostring(s4.material)) end end + -- HEIGHT TINT ramp image (tileset shader 0.27): the gradient's basename, + -- Lua-side state like the biome key rather than a knob + if T.getRamp then + local rampFile = T.getRamp() + if rampFile and rampFile ~= "" then + lines[#lines + 1] = string.format("\tramp = %q,", tostring(rampFile)) + end + end + -- per-texture albedo tints of painted variants (SURFACE > GRADING), sorted + if T.getSlotTints then + local tints = T.getSlotTints() or {} + local names = {} + for a, c in pairs(tints) do + if type(a) == "string" and type(c) == "table" then + names[#names + 1] = a + end + end + table.sort(names) + if #names > 0 then + lines[#lines + 1] = "\tslot_tints = {" + for _, a in ipairs(names) do + local c = tints[a] + lines[#lines + 1] = string.format( + "\t\t[%q] = { %s, %s, %s },", + a, + fmtNum(tonumber(c[1]) or 1), + fmtNum(tonumber(c[2]) or 1), + fmtNum(tonumber(c[3]) or 1) + ) + end + lines[#lines + 1] = "\t}," + end + end -- keys sorted so repeated saves of unchanged state serialize identically -- (project files live in git) local knobs = T.getKnobs() or {} @@ -1619,6 +1767,7 @@ local function finishSave() echoP("saved project '" .. job.slug .. "' to " .. job.dir) currentSlug = job.slug lastSaveInfo = { ok = true, slug = job.slug } + touchRecent(currentSlug) for _, s in ipairs(job.sections) do echoP(string.format(" %-12s %s (%d bytes%s)", s.name, s.file, s.bytes, s.extra and (", " .. s.extra) or "")) end @@ -1647,6 +1796,7 @@ local function startSave(slug, opts) echoP("cannot save: " .. err) return false end + slug = ok if not heightmapPNG then heightmapPNG = VFS.Include("luaui/Widgets/cmd_terraform_brush_png.lua") end @@ -1667,10 +1817,11 @@ end -- Does a project folder with a readable manifest exist? (UI overwrite guard: -- Save As over an existing project asks for a second click first.) local function projectExists(slug) - if not validateSlug(slug) then + local ok = validateSlug(slug) + if not ok then return false end - return readPrevManifest(PROJECTS_DIR .. slug .. "/") ~= nil + return readPrevManifest(PROJECTS_DIR .. ok .. "/") ~= nil end -- Does a saved project include a units section? (UI confirm guard: warns @@ -1680,32 +1831,74 @@ local function projectHasUnits(slug) if not ok then return false end - local manifest = readPrevManifest(PROJECTS_DIR .. slug .. "/") + local manifest = readPrevManifest(PROJECTS_DIR .. ok .. "/") return (manifest and manifest.sections and manifest.sections.units) and true or false end +-- One Open Project row. `folder` is the slug's parent path ("" at the root); +-- `last_touched` comes from the recent-projects journal (nil when never +-- opened or saved through this widget). +local function projectEntry(slug, manifest, touchedAt) + local m = manifest.map or {} + return { + slug = slug, + folder = slug:match("^(.*)/[^/]+$") or "", + name = manifest.name or slug:match("([^/]+)$") or slug, + size_x = tonumber(m.size_x), + size_z = tonumber(m.size_z), + created = manifest.created, + modified = manifest.modified or manifest.created, + last_touched = touchedAt, + format_version = tonumber(manifest.format_version), + } +end + +-- Folder walk for the listing, MAX_SLUG_DEPTH deep: a folder with project.lua +-- is a project and is not descended into; one without is a container. Hidden +-- folders (".git" in a cloned repository) and names validateSlug rejects are +-- skipped. +local function walkProjects(rel, depth, out, seen, touchedAt) + local dirs = VFS.SubDirs(PROJECTS_DIR .. (rel ~= "" and (rel .. "/") or ""), "*", VFS.RAW) or {} + for _, d in ipairs(dirs) do + local seg = d:match("([^/\\]+)[/\\]*$") + if seg and seg:sub(1, 1) ~= "." then + local slug = rel == "" and seg or (rel .. "/" .. seg) + if validateSlug(slug) then + local manifest = readPrevManifest(PROJECTS_DIR .. slug .. "/") + if manifest and manifest.kind == "bar-map-project" then + seen[slug] = true + out[#out + 1] = projectEntry(slug, manifest, touchedAt[slug]) + elseif not manifest and depth < MAX_SLUG_DEPTH then + walkProjects(slug, depth + 1, out, seen, touchedAt) + end + end + end + end +end + -- Enumerate projects with manifest details for the Open Project dialog. --- VFS.SubDirs sees the folders; manifests are read via raw io (same-session --- folders may be invisible/stale in the VFS view — SubDirs RAW semantics for --- folders created THIS session are unpinned, so a just-saved project may need --- an engine restart to appear; the dialog says so when the list is empty). +-- VFS.SubDirs sees the folders (walked as a tree, see walkProjects); manifests +-- are read via raw io (same-session folders may be invisible/stale in the VFS +-- view — SubDirs RAW semantics for folders created THIS session are unpinned). +-- The recent-projects journal then adds any project the snapshot missed whose +-- manifest raw io can read, so a project saved this session or a fresh clone +-- that was opened once still lists. Sorted newest-modified first; the dialog +-- re-sorts per its own control. local function listProjectsDetailed() - local out = {} - local dirs = VFS.SubDirs(PROJECTS_DIR, "*", VFS.RAW) or {} - for _, d in ipairs(dirs) do - local slug = d:match("([^/\\]+)[/\\]*$") - if slug then - local manifest = readPrevManifest(PROJECTS_DIR .. slug .. "/") + local out, seen, touchedAt = {}, {}, {} + local recent = readRecent() + for _, e in ipairs(recent) do + touchedAt[e.slug] = e.at + end + walkProjects("", 1, out, seen, touchedAt) + for _, e in ipairs(recent) do + if not seen[e.slug] then + local manifest = readPrevManifest(PROJECTS_DIR .. e.slug .. "/") if manifest and manifest.kind == "bar-map-project" then - local m = manifest.map or {} - out[#out + 1] = { - slug = slug, - name = manifest.name or slug, - size_x = tonumber(m.size_x), - size_z = tonumber(m.size_z), - modified = manifest.modified, - format_version = tonumber(manifest.format_version), - } + seen[e.slug] = true + local p = projectEntry(e.slug, manifest, e.at) + p.discovered = "recent" + out[#out + 1] = p end end end @@ -1737,12 +1930,13 @@ local function listProjects() return #found end --- Delete a project folder. validateSlug already rejects anything with a path --- separator, so the target can only ever be one directory under PROJECTS_DIR, --- and a readable manifest is required — never delete a folder this widget did --- not write. The manifest goes first on purpose: if a file is locked and the --- sweep leaves junk behind, the project has already stopped listing (both list --- paths need project.lua) instead of showing up half-deleted. +-- Delete a project folder. validateSlug only admits letter/digit/_/- segments +-- joined by "/", so the target is always a folder under PROJECTS_DIR (never +-- "..", never an absolute path), and a readable manifest is required — never +-- delete a folder this widget did not write. Parent folders of a nested +-- project are left alone. The manifest goes first on purpose: if a file is +-- locked and the sweep leaves junk behind, the project has already stopped +-- listing (both list paths need project.lua) instead of showing up half-deleted. local function deleteProject(slug) if job then echoP("cannot delete a project while a save is running") @@ -1757,6 +1951,7 @@ local function deleteProject(slug) echoP("cannot delete: " .. err) return false end + slug = ok local dir = PROJECTS_DIR .. slug .. "/" if not readPrevManifest(dir) then echoP("cannot delete '" .. slug .. "': no readable project.lua in " .. dir) @@ -2143,6 +2338,10 @@ local function phaseSurface(c) end sp.applySlots(picks) end + -- per-texture INFLUENCE profiles (absent in older projects = none) + if sp.setInfluenceTable then + sp.setInfluenceTable(meta.influence) + end elseif not T then echoP( "WARNING: surface.png present but the tileset widget is not loaded — the mask loads with no variants bound" @@ -2221,6 +2420,18 @@ local function phaseTileset(c) if d.metal_style and d.metal_style ~= "" and T.setMetalStyle then T.setMetalStyle(d.metal_style) end + if type(d.slot_tints) == "table" and T.setSlotTint then + for a, col in pairs(d.slot_tints) do + if type(a) == "string" and type(col) == "table" then + T.setSlotTint(a, col[1], col[2], col[3]) + end + end + end + -- HEIGHT TINT ramp image: an absent key clears any ramp left over from the + -- previous scene, so a project without one loads clean + if T.setRamp then + T.setRamp((type(d.ramp) == "string") and d.ramp or "") + end local applied, unknown = 0, 0 if type(d.knobs) == "table" and T.setKnob then local live = T.getKnobs() or {} @@ -2728,6 +2939,14 @@ local function finishLoad() ) Spring.SendCommands("forcestart") end + + -- A loaded project is there to be edited: bring the Terraformer up + -- (requested by PtaQ 2026-09-04). The panel widget owns the how. + ---@type table? + local ui = WG.TerraformBrushUI + if ui and ui.openEditor then + ui.openEditor() + end end local function abortLoad(reason) @@ -2910,6 +3129,7 @@ local function openProject(slug) echoP("cannot open: " .. err) return false end + slug = ok if not isLocalSession() then echoP("cannot open: project loading needs a local singleplayer session") return false @@ -2959,11 +3179,17 @@ local function openProject(slug) return false end echoP(string.format("restarting into a blank %dx%d map for project '%s'...", m.size_x, m.size_z, slug)) + touchRecent(slug) Spring.Restart("", script) return true end -function widget:DrawScreen() +-- Job driver. DrawScreenPost, NOT DrawScreen: the widget handler skips +-- DrawScreen while the interface is hidden and the Terraformer's FOCUS MODE +-- hides it on purpose, so a Save / Open started there would sit until the HUD +-- came back. Nothing here grabs the screen (the thumbnail step renders to its +-- own FBO), so running after the UI pass changes nothing. +function widget:DrawScreenPost() if unitsWaiter then pollUnitsWaiter() end @@ -3043,6 +3269,9 @@ function widget:Initialize() open = openProject, list = listProjects, listDetailed = listProjectsDetailed, + -- { {slug, at}, ... } newest first: projects opened or saved through + -- this widget (the journal behind the dialog's RECENT order). + recent = readRecent, delete = deleteProject, hasUnitsSection = projectHasUnits, exists = projectExists, diff --git a/luaui/Widgets/cmd_splat_painter.lua b/luaui/Widgets/cmd_splat_painter.lua index c934c63c1a4..da618bbdd20 100644 --- a/luaui/Widgets/cmd_splat_painter.lua +++ b/luaui/Widgets/cmd_splat_painter.lua @@ -123,6 +123,25 @@ local smartFilter = { altMin = 0, altMaxEnable = false, altMax = 200, + -- INFLUENCE: soft altitude / slope bands that scale a stroke instead of + -- gating it, remembered per CHANNEL (the surface painter keeps the same + -- shape per texture). Inside this table on purpose: the paint pass runs + -- from DrawWorld, which sits at the Lua 5.1 upvalue ceiling, and this table + -- is already one of its upvalues. _uInf holds the uniform locations for the + -- same reason. + influenceDefault = { + altOn = false, + altMin = 0, + altMax = 200, + altFeatherLo = 40, + altFeatherHi = 40, + slopeOn = false, + slopeMin = 0, + slopeMax = 30, + slopeFeather = 10, + }, + influence = {}, -- channel (1..4) -> profile + _uInf = { altOn = -1, alt = -1, slopeOn = -1, slope = -1 }, -- filled by createShaders } -- Texture state @@ -368,12 +387,44 @@ local PAINT_FRAG_SRC = [[ uniform float sfAltMin; uniform int sfAltMaxEnable; uniform float sfAltMax; + // INFLUENCE (soft bands scaling the stroke; x = min, y = max, z = feather + // below min, w = feather above max; slope in degrees) + uniform int infAltOn; + uniform vec4 infAlt; + uniform int infSlopeOn; + uniform vec4 infSlope; // ------ smart-filter helpers ------ float sampleHeight(vec2 uv) { return texture2D(heightMap, uv).x; } + float smoothBand(float v, float lo, float hi, float fLo, float fHi) { + float a = (fLo > 0.001) ? smoothstep(lo - fLo, lo, v) : step(lo, v); + float b = (fHi > 0.001) ? (1.0 - smoothstep(hi, hi + fHi, v)) : step(v, hi); + return clamp(a * b, 0.0, 1.0); + } + + float influenceAt(vec2 uv) { + if (infAltOn == 0 && infSlopeOn == 0) return 1.0; + float w = 1.0; + if (infAltOn == 1) { + w *= smoothBand(sampleHeight(uv), infAlt.x, infAlt.y, infAlt.z, infAlt.w); + } + if (infSlopeOn == 1) { + vec2 hmTexel = 1.0 / vec2(textureSize(heightMap, 0)); + float hL = sampleHeight(uv + vec2(-hmTexel.x, 0.0)); + float hR = sampleHeight(uv + vec2( hmTexel.x, 0.0)); + float hD = sampleHeight(uv + vec2(0.0, -hmTexel.y)); + float hU = sampleHeight(uv + vec2(0.0, hmTexel.y)); + vec2 cellSize = mapSize * hmTexel; + vec3 n = normalize(vec3(hL - hR, 2.0 * cellSize.x, hD - hU)); + float slopeDeg = degrees(acos(clamp(n.y, 0.0, 1.0))); + w *= smoothBand(slopeDeg, infSlope.x, infSlope.y, infSlope.z, infSlope.w); + } + return w; + } + bool passesSmartFilter(vec2 uv) { if (sfEnabled == 0) return true; @@ -499,6 +550,8 @@ local PAINT_FRAG_SRC = [[ // Falloff float falloff = 1.0 - pow(dist, brushCurve); float amount = brushStrength * falloff; + // INFLUENCE scales paint only; erase always lands at full strength + if (brushErase == 0) amount *= influenceAt(uv); vec4 result = current; if (brushErase == 1) { @@ -585,6 +638,10 @@ local function createShaders() uLocSfAltMin = glGetUniformLocation(paintShader, "sfAltMin") uLocSfAltMaxEnable = glGetUniformLocation(paintShader, "sfAltMaxEnable") uLocSfAltMax = glGetUniformLocation(paintShader, "sfAltMax") + smartFilter._uInf.altOn = glGetUniformLocation(paintShader, "infAltOn") + smartFilter._uInf.alt = glGetUniformLocation(paintShader, "infAlt") + smartFilter._uInf.slopeOn = glGetUniformLocation(paintShader, "infSlopeOn") + smartFilter._uInf.slope = glGetUniformLocation(paintShader, "infSlope") copyShader = glCreateShader({ vertex = PAINT_VERT_SRC, @@ -833,6 +890,13 @@ local function executePaintStroke(worldX, worldZ, rotDeg) glUniform(uLocSfAltMin, sf.altMin) glUniformInt(uLocSfAltMaxEnable, sf.altMaxEnable and 1 or 0) glUniform(uLocSfAltMax, sf.altMax) + -- INFLUENCE profile of the channel being painted + local inf = sf.influence[activeChannel] or sf.influenceDefault + local u = sf._uInf + glUniformInt(u.altOn, inf.altOn and 1 or 0) + glUniform(u.alt, inf.altMin, inf.altMax, inf.altFeatherLo, inf.altFeatherHi) + glUniformInt(u.slopeOn, inf.slopeOn and 1 or 0) + glUniform(u.slope, inf.slopeMin, inf.slopeMax, inf.slopeFeather, inf.slopeFeather) -- Draw fullscreen quad glTexRect(-1, -1, 1, 1, 0, 0, 1, 1) @@ -1096,6 +1160,9 @@ local function getState() exportFormat = EXPORT_FORMATS[exportFormatIndex], smartEnabled = smartFilterEnabled, smartFilters = smartFilter, + -- INFLUENCE profile of the active channel (panel INFLUENCE section) + influenceKey = "channel " .. tostring(activeChannel), + influence = smartFilter.influence[activeChannel] or smartFilter.influenceDefault, splatTexWidth = splatTexWidth, splatTexHeight = splatTexHeight, geoDecalMode = geoDecalMode, @@ -1227,11 +1294,30 @@ local function setSmartEnabled(enabled) end local function setSmartFilter(key, val) - if smartFilter[key] ~= nil then + if key ~= "influence" and key ~= "influenceDefault" and key ~= "_uInf" and smartFilter[key] ~= nil then smartFilter[key] = val end end +-- INFLUENCE: edit the ACTIVE channel's profile (created from the default on +-- first touch); keys are those of smartFilter.influenceDefault. +local function setInfluence(key, val) + local def = smartFilter.influenceDefault + if def[key] == nil then + return false + end + local p = smartFilter.influence[activeChannel] + if not p then + p = {} + for kk, vv in pairs(def) do + p[kk] = vv + end + smartFilter.influence[activeChannel] = p + end + p[key] = val + return true +end + -- ============ WIDGET CALLBACKS ============ function widget:Initialize() @@ -1264,6 +1350,7 @@ function widget:Initialize() setEraseMode = setEraseMode, setSmartEnabled = setSmartEnabled, setSmartFilter = setSmartFilter, + setInfluence = setInfluence, saveSplats = requestSaveSplats, isSavePending = function() return pendingSave @@ -1977,7 +2064,14 @@ function widget:DrawWorld() mode = 7 + activeChannel -- G/B/A -> intermediate/cliff/slot4 end end - T.setSurfacePreview(mode, worldX, worldZ, activeRadius, activeCurve) + T.setSurfacePreview( + mode, + worldX, + worldZ, + activeRadius, + activeCurve, + smartFilter.influence[activeChannel] or smartFilter.influenceDefault + ) end end local groundY = GetGroundHeight(worldX, worldZ) diff --git a/luaui/Widgets/cmd_terraform_brush.lua b/luaui/Widgets/cmd_terraform_brush.lua index ecb2c3135f0..6be99be1fea 100644 --- a/luaui/Widgets/cmd_terraform_brush.lua +++ b/luaui/Widgets/cmd_terraform_brush.lua @@ -14,6 +14,7 @@ end local MSG = { BRUSH = "$terraform_brush$", + STROKE = "$terraform_stroke$", RAMP = "$terraform_ramp$", SPLINE_RAMP = "$terraform_ramp_spline$", RESTORE = "$terraform_restore$", @@ -419,6 +420,30 @@ local BUILTIN_PRESETS = { noiseLacunarity = 2.0, noiseSeed = 0, }, + { + -- MrBob's sculpting setup from the campaign terrain tutorial: clay, the + -- lowest intensity and the sharpest falloff, square riding the stroke. + -- Many weak passes build the form; the sharp edge leaves the striations. + name = "Clay Sculpt", + mode = "raise", + shape = "square", + radius = 120, + rotationDeg = 0, + curve = 0.1, + intensity = 0.1, + lengthScale = 1.0, + heightCapMin = nil, + heightCapMax = nil, + heightCapAbsolute = true, + clayMode = true, + followStroke = true, + noiseType = "perlin", + noiseScale = 64, + noiseOctaves = 4, + noisePersistence = 0.5, + noiseLacunarity = 2.0, + noiseSeed = 0, + }, { name = "Dunes", mode = "noise", @@ -449,6 +474,7 @@ end local activeDirection = nil local activeRadius = DEFAULT_RADIUS local activeShape = "circle" +---@type number local activeRotation = 0 local activeCurve = DEFAULT_CURVE local activeIntensity = DEFAULT_INTENSITY @@ -467,6 +493,34 @@ local extraState = { heightColormap = false, curveOverlay = false, velocityIntensity = false, + -- Settings > Performance: wider dab spacing where the falloff allows it, + -- fewer dabs per tick, coarser FOLLOW STROKE angle steps, panel readouts + -- strided while dragging. Persisted by the panel (ui_prefs.lua). + ---@type boolean + perfMode = false, + -- Settings > Stroke > Clay build-up: legacy per-tick clay stacking (wire + -- clay flag "2"). Off = one layer per stroke over the surface it started + -- on, which is what stops the concentric rings. + ---@type boolean + clayStack = false, + -- FOLLOW STROKE: rotate the brush shape to the stroke tangent while dragging. + -- followAngle is the EMA-smoothed tangent in degrees (nil until the cursor has + -- travelled far enough to define one); followManualRot parks the user's manual + -- rotation for the duration of the drag (activeRotation is driven by the + -- tangent instead, so every preview follows without touching 20 draw sites). + ---@type boolean + followStroke = false, + ---@type number? + followAngle = nil, + ---@type number? + followManualRot = nil, + -- Stroke path buffer: every MouseMove during a sculpt drag appends a world + -- point here (flat x,z pairs). Update walks the polyline instead of a straight + -- chord to the current cursor, so fast scribbles keep their corners. + strokePath = {}, + strokePathN = 0, + strokePathHead = 1, + strokeDabs = {}, gridSnap = false, gridSnapSize = 48, lastDragScreenX = nil, @@ -475,6 +529,7 @@ local extraState = { restoreStrength = 1.0, seismicTimer = 0, -- Protractor: snaps brush rotation to an angle grid + ---@type boolean angleSnap = false, angleSnapStep = 15, -- degrees per step (1–90) angleSnapAuto = true, -- true = auto-snap to nearest spoke each frame; false = manual spoke lock @@ -729,6 +784,18 @@ extraState.setParamHud = function(text) extraState.paramHudTimer = 1.5 end +-- F5 / a map capture hide the whole interface, editor overlays included. FOCUS +-- MODE (the eye button in the panel header) hides the game HUD on purpose and +-- the editor must keep drawing through it, so it does not count as hidden here. +-- extraState field, not a chunk local: this chunk is at the 200-local ceiling. +extraState.interfaceHiddenForEditor = function() + if not Spring.IsGUIHidden() then + return false + end + local ui = WG.TerraformBrushUI + return not (ui and ui.isFocusMode and ui.isFocusMode()) +end + -- Symmetry: get effective origin (fallback to map center) extraState.getSymmetryOrigin = function() local ox = extraState.symmetryOriginX or (Game.mapSizeX * 0.5) @@ -925,12 +992,20 @@ local noiseSeed = 0 local historyUndoCount = 0 local historyRedoCount = 0 --- Tessellation refresh: force mesh re-tessellation for N frames after heightmap edits -local TESS_DIRTY_FRAMES = 10 +-- Tessellation refresh: force the ground mesh to re-tessellate for a couple +-- of frames after a heightmap edit has LANDED. Armed from +-- widget:UnsyncedHeightMapUpdate, which the engine fires exactly when the +-- unsynced heightmap changes. It used to be armed for 10 frames per brush +-- tick from afterBrushTick, so a 20 Hz drag kept the whole visible mesh +-- re-tessellating every single frame, on big maps a frame-rate lever of its +-- own. +local TESS_DIRTY_FRAMES = 2 local tessellationDirtyFrames = 0 local function markTessellationDirty() - tessellationDirtyFrames = TESS_DIRTY_FRAMES + if tessellationDirtyFrames < TESS_DIRTY_FRAMES then + tessellationDirtyFrames = TESS_DIRTY_FRAMES + end end -- Client-side follow-up every whole-map terrain change needs: the mesh @@ -952,6 +1027,7 @@ end -- when the ground actually moved — never on a timer. function widget:UnsyncedHeightMapUpdate() extraState.terrainVersion = extraState.terrainVersion + 1 + markTessellationDirty() end -- Per-tick MERGE_END: each tick creates one undo entry within the current stroke. @@ -959,7 +1035,7 @@ end -- UNDO_STROKE pops all entries for the latest stroke atomically. local function afterBrushTick() - markTessellationDirty() + -- The mesh refresh is armed by UnsyncedHeightMapUpdate when the edit lands. SendLuaRulesMsg(MSG.MERGE_END) end @@ -1226,7 +1302,8 @@ local function sendTerraformMessage(direction, worldX, worldZ, radius, shape, ro local curveStr = string.format("%.1f", curve) local intenStr = string.format("%.1f", effectiveIntensity) local lenStr = string.format("%.1f", activeLengthScale) - local clayStr = clayMode and "1" or "0" + -- "1" = clay, one layer per stroke; "2" = clay with per-tick build-up. + local clayStr = clayMode and (extraState.clayStack and "2" or "1") or "0" local dustStr = (djMode and dustEffects) and "1" or "0" local opacityStr = string.format("%.2f", brushOpacity) local ringStr = string.format("%.2f", ringInnerRatio) @@ -1304,6 +1381,126 @@ local function sendTerraformMessage(direction, worldX, worldZ, radius, shape, ro -- steps + symmetric copies land in the same per-tick undo entry. end +-- One STROKE message for the whole tick instead of one BRUSH message per dab. +-- The gadget reads every dab's centre height from the untouched heightmap +-- first, derives every clay plane from those pre-tick heights, then applies the +-- dabs in order at FULL intensity. Deposit is therefore per distance travelled +-- (what a sculpting app does) instead of the old per-tick intensity split by +-- the dab count, which made a fast stroke deposit almost nothing. Nothing +-- compounds either: every plane in the batch came from the same pre-tick +-- heights, so the rise a tick can produce is still bounded by +-- HEIGHT_STEP * intensity. Non-clay modes are unaffected -- they were already +-- per distance, and a batch applies them in exactly the order the separate +-- messages did. +-- dabs is a flat {x, z, angleDeg, ...} list; nDabs is the dab count. +-- (Attached to extraState: main chunk is at the 200-local limit.) +extraState.sendStrokeDabs = function(direction, dabs, nDabs, radius, shape, curve, flattenHeight) + if nDabs < 1 then + return + end + -- Pen pressure: modulate radius by tablet pressure (per tick, as before) + if extraState.penPressureEnabled and not extraState.penOverUI then + local pm = extraState.penPressureMapped or extraState.penPressure or 0 + local sens = extraState.penPressureSensitivity or 1.0 + if extraState.penPressureModulateSize or extraState.penPressureModulateRadius then + radius = max(8, floor(radius * (1.0 + pm * sens) + 0.5)) + end + end + local absCapMin, absCapMax + if heightCapAbsolute then + absCapMin = heightCapMin and string.format("%.0f", heightCapMin) or "nil" + absCapMax = heightCapMax and string.format("%.0f", heightCapMax) or "nil" + else + absCapMin = (heightCapMin and lockedGroundY) and string.format("%.0f", lockedGroundY + heightCapMin) or "nil" + absCapMax = (heightCapMax and lockedGroundY) and string.format("%.0f", lockedGroundY + heightCapMax) or "nil" + end + -- Same sentinels as the per-dab message: "smooth" / "smudge" ride + -- the flatten slot as non-numeric values. + local flattenStr = (activeMode == "smooth") and "smooth" + or (activeMode == "smudge") and ("smudge" .. (extraState.lastAppliedX == nil and "1" or "0")) + or (flattenHeight and string.format("%.0f", flattenHeight) or "nil") + local penPressureFactor = 1.0 + if extraState.penPressureEnabled and extraState.penPressureModulateIntensity and not extraState.penOverUI then + local pm = extraState.penPressureMapped or extraState.penPressure or 0 + penPressureFactor = 1.0 + pm * (extraState.penPressureSensitivity or 1.0) + end + local effectiveIntensity = activeIntensity + * (extraState.velocityIntensity and extraState.dragVelocityFactor or 1) + * penPressureFactor + local head = " " + .. radius + .. " " + .. shape + .. " " + .. string.format("%.1f", curve) + .. " " + .. absCapMin + .. " " + .. absCapMax + .. " " + .. string.format("%.1f", effectiveIntensity) + .. " " + .. string.format("%.1f", activeLengthScale) + .. " " + .. (clayMode and (extraState.clayStack and "2" or "1") or "0") + .. " " + .. ((djMode and dustEffects) and "1" or "0") + .. " " + .. string.format("%.2f", brushOpacity) + .. " " + .. (isStampMode() and "1" or "0") + .. " " + .. flattenStr + .. " " + .. string.format("%.2f", ringInnerRatio) + -- Symmetry: one message per copy, each carrying that copy's whole dab list. + -- Bucket by copy index so the mirrored paths stay contiguous strokes. + local buckets = extraState.strokeBuckets + if not buckets then + buckets = {} + extraState.strokeBuckets = buckets + end + local nCopies = 0 + for i = 1, nDabs do + local b = (i - 1) * 3 + local wx, wz = dabs[b + 1], dabs[b + 2] + -- Wiggle: sinusoidal offset, phase advanced per dab as it was per stamp + if extraState.wiggleEnabled then + extraState.wigglePhase = extraState.wigglePhase + extraState.wiggleSpdIdx * 3.0 * UPDATE_INTERVAL + local wamp = extraState.wiggleAmpIdx * 0.2 * radius + wx = wx + sin(extraState.wigglePhase) * wamp + wz = wz + sin(extraState.wigglePhase * 1.3 + 2.1) * wamp * 0.7 + end + local positions = extraState.getSymmetricPositions(wx, wz, dabs[b + 3]) + if #positions > nCopies then + nCopies = #positions + end + for k = 1, #positions do + local p = positions[k] + local bucket = buckets[k] + if not bucket then + bucket = {} + buckets[k] = bucket + end + local o = (i - 1) * 3 + bucket[o + 1] = floor(p.x) + bucket[o + 2] = floor(p.z) + bucket[o + 3] = floor(p.rot + 0.5) % 360 + end + end + local isFlipped = extraState.symmetryFlipped + for k = 1, nCopies do + local bucket = buckets[k] + -- Flipped mode: invert direction for mirrored copies (raise becomes lower) + local dir = (isFlipped and k > 1) and -direction or direction + SendLuaRulesMsg(MSG.STROKE .. dir .. head .. " " .. nDabs .. " " .. table.concat(bucket, " ", 1, nDabs * 3)) + for i = #bucket, 1, -1 do + bucket[i] = nil + end + end + -- Caller calls afterBrushTick() ONCE so the whole tick is one undo entry. +end + local function parseRadius(args) if args and args[1] then local radius = tonumber(args[1]) @@ -1389,6 +1586,15 @@ local function deactivateTerraform() end invalidateDrawCache() + -- Deactivating mid-drag skips the release cleanup in Update, so hand the + -- user's rotation back here too. + if extraState.followManualRot then + activeRotation = extraState.followManualRot + extraState.followManualRot = nil + end + extraState.followAngle = nil + extraState.strokePathN = 0 + extraState.strokePathHead = 1 activeDirection = nil activeMode = nil extraState.heightSamplingMode = nil -- cancel pending height sampling @@ -1604,12 +1810,118 @@ local function snapDragToSpoke(wx, wz) return dragOriginX + spokeX * projDist, dragOriginZ + spokeZ * projDist end +-- Append one world point to the stroke path, dropping jitter and bounding the +-- backlog. Points are stored flat (x, z pairs) from strokePathHead to +-- strokePathN; widget:Update consumes them. +extraState.pushStrokePoint = function(wx, wz) + local path = extraState.strokePath + local n = extraState.strokePathN + if n > 0 then + local o = (n - 1) * 2 + local dx = wx - path[o + 1] + local dz = wz - path[o + 2] + -- Sub-elmo jitter cannot move a dab; dropping it keeps the buffer short. + if dx * dx + dz * dz < 1 then + return + end + end + -- Backlog guard: a sustained scribble can outrun the per-tick dab cap. + -- Rather than fall further and further behind the cursor, drop the oldest + -- unconsumed point once the backlog is deep enough to read as lag. + if n - extraState.strokePathHead >= 512 then + extraState.strokePathHead = extraState.strokePathHead + 1 + end + n = n + 1 + local o = (n - 1) * 2 + path[o + 1] = wx + path[o + 2] = wz + extraState.strokePathN = n +end + +-- Stroke path buffer: record the cursor's real path while a sculpt drag runs. +-- widget:Update samples the mouse at 20 Hz, so a fast scribble used to reach the +-- gadget as a straight chord between two samples with its corners cut. MouseMove +-- fires at the engine's mouse rate, so the polyline recorded here is the one the +-- user actually drew; Update resamples it at the brush's own spacing. +-- (Attached to extraState: main chunk is at the 200-local limit.) +extraState.appendStrokePoint = function() + local worldX, worldZ = getWorldMousePositionOnPlane(lockedGroundY) + if not worldX then + return + end + -- Same filters, in the same order, as the Update sampler. + if extraState.angleSnap and dragOriginX then + worldX, worldZ = snapDragToSpoke(worldX, worldZ) + else + local _, _, _, shiftHeld = GetModKeyState() + if shiftHeld and (shiftState.originX or dragOriginX) then + local ox = shiftState.originX or dragOriginX + local oz = shiftState.originZ or dragOriginZ + worldX, worldZ = constrainToAxis(ox, oz, worldX, worldZ) + end + end + if extraState.measureActive and extraState.measureRulerMode then + worldX, worldZ = extraState.snapToMeasureLine(worldX, worldZ) + end + extraState.pushStrokePoint(worldX, worldZ) +end + +-- Drag start: empty the path buffer and park the manual rotation so FOLLOW can +-- drive activeRotation for the length of the stroke (see the release cleanup in +-- widget:Update, which restores it). +extraState.startStrokePath = function() + extraState.strokePathN = 0 + extraState.strokePathHead = 1 + extraState.followAngle = nil + -- Only park the manual rotation when FOLLOW will actually overwrite it: + -- with FOLLOW off a mid-drag Alt+scroll must survive the release. + extraState.followManualRot = extraState.followStroke and activeRotation or nil +end + +-- Rotation for one dab of a stroke. With FOLLOW STROKE off this is just the +-- user's manual rotation; with it on the shape rides the path tangent, EMA +-- smoothed so mouse jitter does not spin a square on the spot. +---@return number degrees +extraState.followAngleFor = function(dx, dz) + if not extraState.followStroke or activeShape == "fill" then + return activeRotation + end + local target = atan2(dz, dx) * 180 / pi + local cur = extraState.followAngle + if cur then + local delta = ((target - cur + 180) % 360) - 180 + cur = (cur + delta * 0.35) % 360 + else + -- First tangent of the drag: snap, no easing from a stale angle. + cur = target % 360 + end + extraState.followAngle = cur + if extraState.angleSnap then + -- Protractor on: the tangent lands on the spoke grid like everything else. + local st = extraState.angleSnapStep + if st and st > 0 then + return (floor(cur / st + 0.5) * st) % 360 + end + end + -- Quantise to 2 degrees, the same step the gadget's falloff-stamp cache keys + -- the angle at (quantiseStampParams): finer than this only costs cache misses, + -- coarser than this visibly steps the shape around on a curve. Performance + -- mode steps 6 degrees: a third of the stamp builds on shaped brushes. + local q = extraState.perfMode and 6 or 2 + return (floor(cur / q + 0.5) * q) % 360 +end + local function setRotation(degrees) activeRotation = degrees % 360 if extraState.angleSnap and extraState.angleSnapStep > 0 then local s = extraState.angleSnapStep activeRotation = (floor(activeRotation / s + 0.5) * s) % 360 end + -- Mid-drag rotate while FOLLOW is steering: keep the parked manual value in + -- step, or the release would hand back the pre-drag angle instead. + if extraState.followManualRot then + extraState.followManualRot = activeRotation + end end local function setCurve(value) @@ -1827,6 +2139,7 @@ local function savePreset(name) gridSnapSize = extraState.gridSnapSize, curveOverlay = extraState.curveOverlay, velocityIntensity = extraState.velocityIntensity, + followStroke = extraState.followStroke, restoreStrength = extraState.restoreStrength, dustEffects = dustEffects, seismicEffects = seismicEffects, @@ -1872,6 +2185,7 @@ local function savePreset(name) file:write(string.format("\tgridSnapSize = %s,\n", tostring(data.gridSnapSize))) file:write(string.format("\tcurveOverlay = %s,\n", tostring(data.curveOverlay))) file:write(string.format("\tvelocityIntensity = %s,\n", tostring(data.velocityIntensity))) + file:write(string.format("\tfollowStroke = %s,\n", tostring(data.followStroke))) file:write(string.format("\trestoreStrength = %s,\n", tostring(data.restoreStrength))) file:write(string.format("\tdustEffects = %s,\n", tostring(data.dustEffects))) file:write(string.format("\tseismicEffects = %s,\n", tostring(data.seismicEffects))) @@ -1949,6 +2263,10 @@ local function loadPreset(name) extraState.lastDragScreenY = nil end end + if data.followStroke ~= nil then + extraState.followStroke = data.followStroke and true or false + extraState.followAngle = nil + end if tonumber(data.restoreStrength) then extraState.restoreStrength = max(0.0, min(1.0, tonumber(data.restoreStrength))) end @@ -2330,6 +2648,11 @@ local function getState() rampAutoAttach = extraState.rampAutoAttach, curveOverlay = extraState.curveOverlay, velocityIntensity = extraState.velocityIntensity, + followStroke = extraState.followStroke, + perfMode = extraState.perfMode, + clayStack = extraState.clayStack, + -- A sculpt drag is in progress (brush down on the world). + dragging = lockedWorldX ~= nil, dragVelocityFactor = extraState.dragVelocityFactor, restoreStrength = extraState.restoreStrength, @@ -3244,6 +3567,13 @@ function widget:Initialize() gbAltMin = true, spAltMax = true, spAltMin = true, + -- SURFACE panel: soft FILTERS (surface painter), INFLUENCE band per engine + sfAltMax = true, + sfAltMin = true, + sfInfAltMax = true, + sfInfAltMin = true, + spInfAltMax = true, + spInfAltMin = true, } extraState.heightSamplingMode = valid[target] and target or nil if extraState.heightSamplingMode then @@ -3254,6 +3584,16 @@ function widget:Initialize() return extraState.heightSamplingMode end, setCurveOverlay = setCurveOverlay, + setFollowStroke = function(value) + extraState.followStroke = value and true or false + if not extraState.followStroke then + extraState.followAngle = nil + if extraState.followManualRot then + activeRotation = extraState.followManualRot + extraState.followManualRot = nil + end + end + end, setVelocityIntensity = function(value) extraState.velocityIntensity = value and true or false if not extraState.velocityIntensity then @@ -3262,6 +3602,12 @@ function widget:Initialize() extraState.lastDragScreenY = nil end end, + setPerfMode = function(value) + extraState.perfMode = value and true or false + end, + setClayStack = function(value) + extraState.clayStack = value and true or false + end, setRestoreStrength = function(value) extraState.restoreStrength = max(0.0, min(1.0, tonumber(value) or 1.0)) end, @@ -4254,8 +4600,11 @@ function widget:Update(dt) end updateTimer = 0 - -- Warm the gadget's falloff-stamp cache ahead of the first apply - if activeMode ~= "ramp" then + -- Warm the gadget's falloff-stamp cache ahead of the first apply. + -- Skipped while FOLLOW STROKE is steering the angle: it changes every tick, + -- so warming would send a message per tick to precompute a stamp the very + -- next dab builds anyway. + if activeMode ~= "ramp" and not (extraState.followStroke and extraState.followAngle) then local warmSig = activeShape .. "|" .. activeRadius @@ -4319,6 +4668,15 @@ function widget:Update(dt) extraState.lastDragScreenY = nil extraState.dragVelocityFactor = 1.0 extraState.erodePhase = 0 + -- FOLLOW STROKE drove activeRotation for the drag; hand the user's own + -- rotation back so the idle ring preview is the one they set. + if extraState.followManualRot then + activeRotation = extraState.followManualRot + extraState.followManualRot = nil + end + extraState.followAngle = nil + extraState.strokePathN = 0 + extraState.strokePathHead = 1 rampEndX = nil rampEndZ = nil extraState.autorampLastX = nil @@ -4629,71 +4987,117 @@ function widget:Update(dt) fh = lockedGroundY end - -- Interpolated stamps: bridge the gap between last applied position and - -- current so fast mouse moves still produce a connected stroke. - -- Skipped in stamp mode (which is discrete stamps by design). + -- Stroke resampling: walk the path the cursor actually drew (recorded by + -- extraState.appendStrokePoint at mouse rate) and drop a dab every stepSize + -- along it. The old code bridged a straight chord from the last applied + -- point to the current 20 Hz sample, so a fast scribble lost its corners. + -- Stamp mode is discrete by design and stays one dab at the cursor. + -- Close the polyline on the current cursor before walking it. MouseMove may + -- not have fired since the last tick, and this also means the walk degrades + -- to exactly the old straight-chord bridge if it never fires at all. + if lockedWorldX and not isStampMode() then + extraState.pushStrokePoint(lockedWorldX, lockedWorldZ) + end + local dabs = extraState.strokeDabs + local nDabs = 0 + -- The angle the last dab of the tick used; FOLLOW hands it to the previews. + local lastAngle = activeRotation local prevX, prevZ = extraState.lastAppliedX, extraState.lastAppliedZ - local steps = 1 local endX, endZ = lockedWorldX, lockedWorldZ - if prevX and not isStampMode() then - local ddx = endX - prevX - local ddz = endZ - prevZ - local dist = (ddx * ddx + ddz * ddz) ^ 0.5 + if prevX and prevZ and not isStampMode() then -- Denser overlap (~15% of radius) eliminates visible banding at - -- slow-to-mid drag speeds. - local stepSize = max(4, activeRadius * 0.15) - steps = floor(dist / stepSize + 0.5) - if steps < 1 then - steps = 1 - end - -- The step cap must never widen the spacing: bridging the whole - -- distance with capped steps spread the stamps out past the brush - -- radius on fast drags, which smudge renders as terrain ribs (and - -- past 2R every dab re-grabs instead of painting). Saturate the - -- travel instead - the stroke lags the cursor and the remainder is - -- bridged on the following ticks, so the path stays gapless. - if steps > 48 then - steps = 48 - local travel = steps * stepSize - endX = prevX + ddx / dist * travel - endZ = prevZ + ddz / dist * travel - end - end - if steps <= 1 or not prevX then - sendTerraformMessage( - activeDirection, - endX, - endZ, - activeRadius, - activeShape, - activeRotation, - activeCurve, - fh - ) - else - -- Clay mode is additive (each stamp compounds on current height). - -- Without compensation, N interpolated stamps = Nx the stroke force - -- → runaway rise. Split the per-tick intensity across substeps. - -- Non-clay modes aren't additive, so leave full intensity. - if clayMode then - extraState.interpIntensityScale = 1 / steps - end - for i = 1, steps do - local t = i / steps - local ix = prevX + (endX - prevX) * t - local iz = prevZ + (endZ - prevZ) * t + -- slow-to-mid drag speeds. Performance mode widens the spacing where + -- the falloff can take it: a soft curve (<= 1) sums smoothly at a + -- quarter radius, clay converges on one plane whatever the spacing, + -- hard curves keep the full density. It also caps the dabs a tick + -- may carry, so a saturated tick costs two thirds. + local spacing = 0.15 + local dabCap = 48 + if extraState.perfMode then + dabCap = 32 + if clayMode or activeCurve <= 1.0 then + spacing = 0.24 + elseif activeCurve <= 2.0 then + spacing = 0.2 + end + end + local stepSize = max(4, activeRadius * spacing) + local path = extraState.strokePath + local head = extraState.strokePathHead + local pathN = extraState.strokePathN + local cx, cz = prevX, prevZ + -- The cap bounds a saturated tick; whatever is left of the recorded path + -- stays in the buffer for the next tick, so the stroke lags the cursor + -- but never gaps and never spaces the dabs out past the brush. + while head <= pathN and nDabs < dabCap do + local o = (head - 1) * 2 + local ddx = path[o + 1] - cx + local ddz = path[o + 2] - cz + local dist = (ddx * ddx + ddz * ddz) ^ 0.5 + if dist >= stepSize then + -- One recorded segment can carry many dabs: step toward the same + -- target again from the new position rather than consuming it. + local t = stepSize / dist + cx = cx + ddx * t + cz = cz + ddz * t + local b = nDabs * 3 + nDabs = nDabs + 1 + dabs[b + 1] = cx + dabs[b + 2] = cz + lastAngle = extraState.followAngleFor(ddx, ddz) + dabs[b + 3] = lastAngle + else + head = head + 1 + end + end + if head > pathN then + -- Fully consumed: reuse the array slots from the start. + head = 1 + extraState.strokePathN = 0 + end + extraState.strokePathHead = head + if nDabs > 0 then + endX, endZ = cx, cz + end + end + if nDabs == 0 then + -- Nothing recorded to walk: stamp mode, the first dab of a drag, or a + -- stationary hold (which keeps depositing, as it always has). + nDabs = 1 + dabs[1] = endX + dabs[2] = endZ + lastAngle = (extraState.followStroke and extraState.followAngle) or activeRotation + dabs[3] = lastAngle + end + -- WYSIWYG: while FOLLOW drives the shape, drive activeRotation with it too + -- so the ring preview, ground fill and colormap all show the dab that will + -- land. The user's manual rotation is parked in followManualRot at drag + -- start and restored by the release cleanup. + if extraState.followStroke and extraState.followAngle then + activeRotation = lastAngle + end + if extraState.measureRulerMode and extraState.measureStickyMode then + -- Sticky replay snapshots strokes per dab: keep it on the per-dab + -- message (and therefore on the old clay intensity split). + if clayMode and nDabs > 1 then + extraState.interpIntensityScale = 1 / nDabs + end + for i = 1, nDabs do + local b = (i - 1) * 3 sendTerraformMessage( activeDirection, - ix, - iz, + dabs[b + 1], + dabs[b + 2], activeRadius, activeShape, - activeRotation, + dabs[b + 3], activeCurve, fh ) end extraState.interpIntensityScale = 1 + else + extraState.sendStrokeDabs(activeDirection, dabs, nDabs, activeRadius, activeShape, activeCurve, fh) end -- Per-tick MERGE_END: each tick = one undo entry, all tagged with same stroke ID. -- UNDO_STROKE pops entire stroke atomically. closeBrushStroke() on mouse release. @@ -7326,7 +7730,12 @@ function extraState.measureFindNearEndpoint(sx, sy) return best end -function widget:DrawScreen() +-- Deferred jobs that need a draw call-in: New Map DNTS apply and procedural +-- drive (post-reload), heightmap export and import. DrawScreenPost, NOT +-- DrawScreen: the widget handler skips DrawScreen while the interface is hidden +-- and FOCUS MODE (the panel's eye button) hides it on purpose, so a job queued +-- there would sit until the HUD came back. DrawScreenPost runs every frame. +function widget:DrawScreenPost() if not extraState._newmapDNTSApplied then extraState._newmapTryApplyDNTS() end @@ -7344,6 +7753,9 @@ function widget:DrawScreen() if importHeightRows then doImportHeightmapSend() end +end + +function widget:DrawScreen() -- Height colormap contour labels: height values at topo-line / brush-edge intersections if extraState.heightColormap and extraState.colormapLabels then for _, lbl in ipairs(extraState.colormapLabels) do @@ -8004,13 +8416,13 @@ function widget:DrawWorld() end -- Full-map grid overlay: visible across the whole map regardless of brush active state. - -- Skipped with the interface hidden (F5) or while a map capture is walking - -- the camera — the capture reprojects the rendered frame, so the grid would - -- be baked into the exported photo. Conditions are inlined rather than - -- hoisted into a helper: this chunk is at the Lua 5.1 200-local ceiling. + -- Skipped with the interface hidden (F5, but not FOCUS MODE) or while a map + -- capture is walking the camera — the capture reprojects the rendered frame, + -- so the grid would be baked into the exported photo. Conditions are inlined + -- rather than hoisted into a helper: this chunk is at the Lua 5.1 200-local ceiling. if gridOverlay - and not Spring.IsGUIHidden() + and not extraState.interfaceHiddenForEditor() and not (WG.TerraformCapture and WG.TerraformCapture.isBusy and WG.TerraformCapture.isBusy()) then -- Debounce rebuilds: while actively terraforming, `gridDirty` flips true @@ -8035,7 +8447,7 @@ function widget:DrawWorld() -- overlay, and a map capture would otherwise bake it into the photo). if extraState.waterPreviewLevel - and not Spring.IsGUIHidden() + and not extraState.interfaceHiddenForEditor() and not (WG.TerraformCapture and WG.TerraformCapture.isBusy and WG.TerraformCapture.isBusy()) then extraState.drawWaterLevelPreview() @@ -8467,14 +8879,14 @@ function widget:DrawWorld() -- Suppress brush outline when placing/hovering/dragging symmetry origin, or -- whenever the map labels tool owns the cursor (placing, dot hover/drag, -- over its windows, or a comment is open) - -- ...or whenever the interface is hidden (F5) or a map capture is walking the - -- camera. Both mean "no cursor furniture in the world": the capture - -- reprojects the rendered frame, so a ring drawn here is baked into the - -- exported photo (and the symmetry mirror bakes in a second one). + -- ...or whenever the interface is hidden (F5, but not FOCUS MODE) or a map + -- capture is walking the camera. Both mean "no cursor furniture in the + -- world": the capture reprojects the rendered frame, so a ring drawn here is + -- baked into the exported photo (and the symmetry mirror bakes in a second one). local suppressBrush = extraState.symmetryPlacingOrigin or extraState.symmetryDraggingOrigin or extraState.symmetryHoveringOrigin - or Spring.IsGUIHidden() + or extraState.interfaceHiddenForEditor() or (WG.TerraformCapture and WG.TerraformCapture.isBusy and WG.TerraformCapture.isBusy()) or (WG.MapLabels and WG.MapLabels.shouldSuppressBrush and WG.MapLabels.shouldSuppressBrush()) @@ -9133,6 +9545,13 @@ function widget:MousePress(mx, my, button) extraState.heightSamplingMode = nil if sampledH then local rounded = floor(sampledH + 0.5) + -- SURFACE panel engines (soft + hard), aliased once: the analyzer counts + -- every WG mention in this file, and the main chunk has no room for a + -- file-level alias + ---@type table? + local sfp = WG.SurfacePainter + ---@type table? + local spp = WG.SplatPainter if sampledTarget == "max" then setHeightCapMax(rounded) elseif sampledTarget == "min" then @@ -9179,6 +9598,45 @@ function widget:MousePress(mx, my, button) end WG.SplatPainter.setSmartFilter("altMinEnable", true) WG.SplatPainter.setSmartFilter("altMin", rounded) + elseif sampledTarget == "sfAltMax" and sfp then + local sf = (sfp.getState() or {}).smartFilters or {} + if sf.altMinEnable and rounded < (sf.altMin or 0) then + sfp.setSmartFilter("altMin", rounded) + end + sfp.setSmartFilter("altMaxEnable", true) + sfp.setSmartFilter("altMax", rounded) + elseif sampledTarget == "sfAltMin" and sfp then + local sf = (sfp.getState() or {}).smartFilters or {} + if sf.altMaxEnable and rounded > (sf.altMax or 0) then + sfp.setSmartFilter("altMax", rounded) + end + sfp.setSmartFilter("altMinEnable", true) + sfp.setSmartFilter("altMin", rounded) + elseif + sampledTarget == "sfInfAltMax" + or sampledTarget == "sfInfAltMin" + or sampledTarget == "spInfAltMax" + or sampledTarget == "spInfAltMin" + then + -- INFLUENCE altitude band: the sampled height becomes one bound, the + -- other bound is pushed along if the band would invert, the band is + -- switched on. The engine is the one the panel's mode is driving. + local eng = (sampledTarget:sub(1, 2) == "sf") and sfp or spp + if eng and eng.setInfluence and eng.getState then + local inf = (eng.getState() or {}).influence or {} + if sampledTarget:sub(-3) == "Max" then + if rounded < (inf.altMin or 0) then + eng.setInfluence("altMin", rounded) + end + eng.setInfluence("altMax", rounded) + else + if rounded > (inf.altMax or 0) then + eng.setInfluence("altMax", rounded) + end + eng.setInfluence("altMin", rounded) + end + eng.setInfluence("altOn", true) + end end if WG.TerraformBrushUI and WG.TerraformBrushUI.onHeightSampled then WG.TerraformBrushUI.onHeightSampled(sampledTarget, rounded) @@ -9398,6 +9856,7 @@ function widget:MousePress(mx, my, button) extraState.lastAppliedX = nil extraState.lastAppliedZ = nil extraState.mergeLeftOpen = false + extraState.startStrokePath() -- If shift already held, use existing shift origin for drag if shiftState.originX then dragOriginX = shiftState.originX @@ -9447,6 +9906,7 @@ function widget:MousePress(mx, my, button) extraState.lastAppliedX = nil extraState.lastAppliedZ = nil extraState.mergeLeftOpen = false + extraState.startStrokePath() end return true end @@ -9561,6 +10021,23 @@ function widget:MouseRelease(mx, my, button) end function widget:MouseMove(mx, my, _dx, _dy, button) + -- Stroke path: while a sculpt drag runs, record every mouse move. Update + -- resamples the recorded polyline, so the dabs follow the path the user drew + -- rather than a chord between two 20 Hz samples. Ramp / restore / erode / + -- autoramp / noise own their sampling and are left alone. + if + activeMode + and lockedGroundY + and activeMode ~= "ramp" + and activeMode ~= "restore" + and activeMode ~= "erode" + and activeMode ~= "autoramp" + and activeMode ~= "noise" + and activeShape ~= "fill" + and not isStampMode() + then + extraState.appendStrokePoint() + end -- Measure tool: handle drag-threshold detection and endpoint dragging if extraState.measureActive and extraState.measureDrawing and button == 1 then local DRAG_THRESHOLD_SQ = 9 -- 3 pixels: activate drag almost immediately on move diff --git a/luaui/Widgets/cmd_terraform_brush_capture.lua b/luaui/Widgets/cmd_terraform_brush_capture.lua index 7b9652e6b90..864994fd893 100644 --- a/luaui/Widgets/cmd_terraform_brush_capture.lua +++ b/luaui/Widgets/cmd_terraform_brush_capture.lua @@ -450,6 +450,13 @@ end -- Scene state save / restore -------------------------------------------------------------------------------- +-- FOCUS MODE (the panel's eye button) hides the game interface on purpose; a +-- capture may start under it and has to leave it hidden when done. +local function focusModeOn() + local ui = WG.TerraformBrushUI + return (ui and ui.isFocusMode and ui.isFocusMode()) == true +end + -- Everything the walk changes is restored through this one function, which also -- runs from Shutdown and from the cancel path: a capture that dies half way -- through must never leave the session with invisible units. @@ -495,7 +502,8 @@ local function restoreScene() j.savedSampleRate = nil end if j.guiHidden then - pcall(Spring.SendCommands, "hideinterface 0") + -- Back to what the editor wants, not blindly on: focus mode keeps it hidden. + Spring.SendCommands(focusModeOn() and "hideinterface 1" or "hideinterface 0") j.guiHidden = nil end if j.savedCam then @@ -1656,7 +1664,9 @@ local function startCapture() if job then return false, "a capture is already running" end - if Spring.IsGUIHidden and Spring.IsGUIHidden() then + -- A plain F5 is refused (the user hid the UI, the photo path must not un-hide + -- it behind their back); focus mode is the editor's own hide and is fine. + if Spring.IsGUIHidden and Spring.IsGUIHidden() and not focusModeOn() then return false, "hide-interface is on; turn it off first" end diff --git a/luaui/Widgets/cmd_terraform_image_overlay.lua b/luaui/Widgets/cmd_terraform_image_overlay.lua new file mode 100644 index 00000000000..352c20ed07d --- /dev/null +++ b/luaui/Widgets/cmd_terraform_image_overlay.lua @@ -0,0 +1,403 @@ +local widget = widget ---@type Widget + +function widget:GetInfo() + return { + name = "Terraform Image Overlay", + desc = "Lays a reference image from Terraform Brush/Overlays/ over the terrain (DISPLAY > Image in the Terraformer)", + author = "PtaQ", + date = "September 2026", + license = "GNU GPL, v2 or later", + layer = -4, + enabled = false, -- enabled on demand by the Terraform Suite launcher + } +end + +-- Reference-image overlay for the map editor. The user drops an image into +-- /Terraform Brush/Overlays/ (the install folder on Windows), picks +-- it in the IMAGE OVERLAY window and it is projected onto the ground: a real +-- coastline to sculpt against, or a transparent sheet of guide lines whose +-- alpha is kept so only the lines show. +-- +-- Rendering is one fullscreen pass in DrawWorldPreUnit that rebuilds the world +-- position of every pixel from the map gbuffer depth ($map_gbuffer_zvaltex) and +-- samples the image by world XZ. That makes it hug the terrain exactly at any +-- map size for a constant cost, needs no tessellated ground mesh, and units and +-- features drawn afterwards cover it like any decal. The placement (opacity, +-- offset, scale, fit, flips) goes in as uniforms every frame, so slider drags +-- preview live. + +if not gl.CreateShader then + return +end + +local LuaShader = gl.LuaShader +local InstanceVBOTable = gl.InstanceVBOTable +if not LuaShader or not InstanceVBOTable then + return +end + +local OVERLAYS_DIR = "Terraform Brush/Overlays/" +-- Anything DevIL reads; DDS goes through the engine's own loader. +local IMAGE_EXTS = { + png = true, + jpg = true, + jpeg = true, + tga = true, + bmp = true, + dds = true, + tif = true, + tiff = true, + gif = true, +} + +local st = { + enabled = false --[[@as boolean]], + file = nil --[[@as string?]], -- basename inside OVERLAYS_DIR + opacity = 0.5, -- 0..1 + offsetX = 0, -- map fractions, -1..1 (0.5 = half a map to the east) + offsetZ = 0, + scale = 1, -- 1 = the image spans the whole map + fit = "stretch" --[[@as string]], -- "stretch" (fill the map) or "fit" (keep the image aspect) + flipH = false, + flipV = false, +} + +---@type string? +local texName -- VFS path of the loaded image, nil when nothing is loaded +local texW, texH = 0, 0 +local lastError = "" +local fileList = nil -- cached scan of OVERLAYS_DIR (basenames) +---@type table? +local shader +---@type table? +local quadVAO +local allowDeferred = (Spring.GetConfigInt("AllowDeferredMapRendering") == 1) --[[@as boolean]] +local warnedNoDeferred = false + +local shaderSourceCache = { + vssrcpath = "LuaUI/Shaders/terraform_image_overlay.vert.glsl", + fssrcpath = "LuaUI/Shaders/terraform_image_overlay.frag.glsl", + uniformInt = { mapDepths = 0, overlayTex = 1 }, + uniformFloat = { params1 = { 1, 1, 0, 0 }, params2 = { 1, 1, 0, 0 } }, + shaderName = "Terraform Image Overlay", + shaderConfig = {}, +} + +local function clamp(v, lo, hi) + if v < lo then + return lo + elseif v > hi then + return hi + end + return v +end + +local function echo(msg) + Spring.Echo("[Image Overlay] " .. msg) +end + +--------------------------------------------------------------------------- +-- Folder + texture +--------------------------------------------------------------------------- + +local function scanDir() + local out = {} + local files = VFS.DirList(OVERLAYS_DIR, "*", VFS.RAW) or {} + for i = 1, #files do + local base = files[i]:match("[^/\\]+$") or files[i] + local ext = base:match("%.([%w]+)$") + if ext and IMAGE_EXTS[ext:lower()] then + out[#out + 1] = base + end + end + table.sort(out, function(a, b) + return a:lower() < b:lower() + end) + fileList = out + return out +end + +local function unloadTexture() + if texName then + gl.DeleteTexture(texName) + end + texName = nil + texW, texH = 0, 0 +end + +-- Loads OVERLAYS_DIR .. name through the engine's named-texture path. Returns +-- true on success; on failure lastError says why and nothing stays loaded. +local function loadTexture(name) + unloadTexture() + lastError = "" + if not name or name == "" then + return false + end + local path = OVERLAYS_DIR .. name + if not VFS.FileExists(path) then + lastError = "Not found: " .. path + return false + end + -- gl.TextureInfo loads a named file texture on first use and returns nil + -- when the engine could not decode it. + local info = gl.TextureInfo(path) + if not info or (info.xsize or 0) <= 0 or (info.ysize or 0) <= 0 then + lastError = "Could not decode " .. name + gl.DeleteTexture(path) + return false + end + texName = path + texW, texH = info.xsize, info.ysize + return true +end + +-- Aspect-fit divisors for the shader: (1,1) stretches the image over the map; +-- in FIT mode the longer side spans the map and the other is letterboxed. +local function fitDivisors() + if st.fit ~= "fit" or texW <= 0 or texH <= 0 then + return 1, 1 + end + local imgAspect = texW / texH + local mapAspect = Game.mapSizeX / Game.mapSizeZ + if imgAspect >= mapAspect then + return 1, mapAspect / imgAspect + end + return imgAspect / mapAspect, 1 +end + +--------------------------------------------------------------------------- +-- API +--------------------------------------------------------------------------- + +local function setEnabled(v) + v = v and true or false + if v and not texName then + return false + end + st.enabled = v + return true +end + +local function selectFile(name) + if name == nil or name == "" then + unloadTexture() + st.file = nil + st.enabled = false + lastError = "" + return true + end + if loadTexture(name) then + st.file = name + st.enabled = true + return true + end + st.file = nil + st.enabled = false + echo(lastError) + return false, lastError +end + +local function getState() + local fx, fz = fitDivisors() + return { + enabled = st.enabled, + file = st.file, + hasImage = texName ~= nil, + width = texW, + height = texH, + opacity = st.opacity, + offsetX = st.offsetX, + offsetZ = st.offsetZ, + scale = st.scale, + fit = st.fit, + flipH = st.flipH, + flipV = st.flipV, + fitX = fx, + fitZ = fz, + error = lastError, + dir = OVERLAYS_DIR, + supported = allowDeferred, + } +end + +local function resetPlacement() + st.offsetX = 0 + st.offsetZ = 0 + st.scale = 1 + st.fit = "stretch" + st.flipH = false + st.flipV = false +end + +function widget:Initialize() + Spring.CreateDir(OVERLAYS_DIR) + shader = LuaShader.CheckShaderUpdates(shaderSourceCache) + if not shader then + echo("shader failed to compile, the overlay will not draw") + end + quadVAO = InstanceVBOTable.MakeTexRectVAO() + if not allowDeferred then + echo("AllowDeferredMapRendering is off in springsettings.cfg; the overlay cannot project onto the terrain") + end + + WG.TerraformImageOverlay = { + getDir = function() + return OVERLAYS_DIR + end, + list = function(rescan) + if rescan or not fileList then + scanDir() + end + return fileList + end, + select = selectFile, + clear = function() + return selectFile(nil) + end, + setEnabled = setEnabled, + toggle = function() + return setEnabled(not st.enabled) + end, + isEnabled = function() + return st.enabled and texName ~= nil + end, + hasImage = function() + return texName ~= nil + end, + getState = getState, + setOpacity = function(v) + st.opacity = clamp(tonumber(v) or st.opacity, 0, 1) + end, + setOffset = function(x, z) + if x ~= nil then + st.offsetX = clamp(tonumber(x) or st.offsetX, -1, 1) + end + if z ~= nil then + st.offsetZ = clamp(tonumber(z) or st.offsetZ, -1, 1) + end + end, + nudge = function(dx, dz) + st.offsetX = clamp(st.offsetX + (tonumber(dx) or 0), -1, 1) + st.offsetZ = clamp(st.offsetZ + (tonumber(dz) or 0), -1, 1) + end, + setScale = function(v) + st.scale = clamp(tonumber(v) or st.scale, 0.05, 8) + end, + setFit = function(mode) + st.fit = (mode == "fit") and "fit" or "stretch" + end, + setFlip = function(h, v) + if h ~= nil then + st.flipH = h and true or false + end + if v ~= nil then + st.flipV = v and true or false + end + end, + resetPlacement = resetPlacement, + } + + -- /tfimage toggles the overlay + -- /tfimage picks a file from Terraform Brush/Overlays/ + -- /tfimage off unloads it + widgetHandler:AddAction("tfimage", function(_, optLine) + local arg = optLine and optLine:match("^%s*(.-)%s*$") or "" + if arg == "" then + if not setEnabled(not st.enabled) then + echo("no image loaded; put one in " .. OVERLAYS_DIR .. " and pick it in DISPLAY > Image") + end + elseif arg:lower() == "off" then + selectFile(nil) + else + selectFile(arg) + end + return true + end, nil, "t") + + -- Bring back the image the user had up before a reload; the enabled flag + -- only survives when the file still loads. + if st.file then + local wanted = st.enabled + if loadTexture(st.file) then + st.enabled = wanted + else + st.file = nil + st.enabled = false + end + end +end + +function widget:Shutdown() + unloadTexture() + if shader then + shader:Delete() + shader = nil + end + quadVAO = nil + WG.TerraformImageOverlay = nil +end + +function widget:GetConfigData() + return { + enabled = st.enabled, + file = st.file, + opacity = st.opacity, + offsetX = st.offsetX, + offsetZ = st.offsetZ, + scale = st.scale, + fit = st.fit, + flipH = st.flipH, + flipV = st.flipV, + } +end + +function widget:SetConfigData(data) + if type(data) ~= "table" then + return + end + st.file = (type(data.file) == "string" and data.file ~= "") and data.file or nil + st.enabled = data.enabled == true + st.opacity = clamp(tonumber(data.opacity) or st.opacity, 0, 1) + st.offsetX = clamp(tonumber(data.offsetX) or 0, -1, 1) + st.offsetZ = clamp(tonumber(data.offsetZ) or 0, -1, 1) + st.scale = clamp(tonumber(data.scale) or 1, 0.05, 8) + st.fit = (data.fit == "fit") and "fit" or "stretch" + st.flipH = data.flipH == true + st.flipV = data.flipV == true +end + +--------------------------------------------------------------------------- +-- Draw +--------------------------------------------------------------------------- + +function widget:DrawWorldPreUnit() + if not (st.enabled and texName and shader and quadVAO) then + return + end + if not allowDeferred then + if not warnedNoDeferred then + warnedNoDeferred = true + echo("cannot draw: AllowDeferredMapRendering is off") + end + return + end + + local fx, fz = fitDivisors() + + gl.Texture(0, "$map_gbuffer_zvaltex") + gl.Texture(1, texName) + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + gl.Culling(false) + gl.DepthTest(false) + gl.DepthMask(false) + + shader:Activate() + shader:SetUniform("params1", st.opacity, st.scale, st.offsetX, st.offsetZ) + shader:SetUniform("params2", fx, fz, st.flipH and 1 or 0, st.flipV and 1 or 0) + quadVAO:DrawArrays(GL.TRIANGLES) + shader:Deactivate() + + gl.Texture(0, false) + gl.Texture(1, false) + gl.DepthTest(true) +end diff --git a/luaui/Widgets/cmd_terraform_suite.lua b/luaui/Widgets/cmd_terraform_suite.lua index fb2057bd282..3e6092b78fc 100644 --- a/luaui/Widgets/cmd_terraform_suite.lua +++ b/luaui/Widgets/cmd_terraform_suite.lua @@ -36,6 +36,7 @@ local SUITE_WIDGETS = { "Terraform Brush Capture", "Weather Brush", "Water Type Overlay GL4", + "Terraform Image Overlay", "Terraformer Shared RmlUi Helpers", "Terraform Brush UI", "Decal Placer UI", diff --git a/luaui/images/terraform_brush/eye.png b/luaui/images/terraform_brush/eye.png new file mode 100644 index 0000000000000000000000000000000000000000..83b4ce557de25048b59ff6fdc0ccce5a288e65eb GIT binary patch literal 1821 zcmV+&2jcjNP)hYLibR>(4IHkD- z+$FOk92^`R92^`R92^`R92^`RZU*70C=mof6h)EIX9whK*ymGIQ&aLai(;F{A^{G= zZ#ZMK*=+NXp-?Cku+eUJ@G3WHNP|Os1aB&d&BbckcZD;K2inE4lwG0z^?19eFJ|6&e~EwrSI*ceifc z`hi-lj)CKIkq08$+uK{NUcLI`wQJWd_xJbrIt-SuP#GaCRDDxq;B-Pl!iU?oZHxEy z_4TK9AVxCU?nL+W=TiAhKOY+#8@Y1jO3lTK7k>a5lo1QjCxj>23_~dr>(;H)q^73k zf&h9>q%ckJVfSM)W!ex1_@8x-(jNc;B_$ z;vuC{=}CrD6xqm2JUEDO25J8kn+YU?h_qU*lNA*er;Ce=Ge<{9hsl2#@bXW8grlwG)R!FcJ?r7G&+z>Xa| zK0>dMPlb_@5i^`PdGh2J^cg0}_U+rX*n<%&*TIrfR#sM0P*9*}?R;VaM5Y@f_+UFf zKmTh)Umg*v`+o84_VbnkH2U(B_+0ZeQ zZmfZAun}@#Guh9M)LCVKATb*q9sNdfa&r2TB}<;~>FN0cot{`F1E3sre(cz>v~%aq zeTV%p8oEiImzS4MLqkKizrTMVWtKC8KoIut+_^KZt*xz@>KZ+UGGh1c-5;l?rx)U! zK{ik(;C*RnY2M0}D*pMr6-$S?3!iEmW5VTRsDiq zAw|Y<7Ld!tO^>$D#&|@kudlD0)kW{6G`2z~bi)SuI+#s?!+MNkj9F^7E(x$nQAfAU z$;tT)Gu&)857yPy)l#FOn!|f!iqUAijK+k$cm@Nc=Z<=-udlB~DN%VnjsfULp_8>&FzOu5i0jbMgc;xzEFh*c^ zcXt;>j1EG$bcKY3yy)s6WH1;G(7}sjx;h>Ut_YZ^1|cjjFYol;y?YO`j?d;OjCI-B z*`-NINy#(m!ks~{*C!4P4E!~lqcBSZ=6z;n<{=v6B=d14Cp?Y-XO|%mkdu>BI)}?p z{f!$pe!F$+)^(gQVq;@BtJUiDbQ!{puDQ9nVfpgqFX5IM7Z>-|Y%fFTRUlwdFGDkx z#(b{BflcgslLQ#Nr zL0P@;103GNF}HW`-v2~KMn==P0EsI=13VUi`so*x1-Vz~bl^9v&Wk^5n@Q1qB5=xrHZAoH)&Hw|@w>N1!?Qr1<;$NVMxejHdMi>F?Qa4O(DUcde|hfQx%2w<>laD=REqX90PJ%a z|Ag`_EiK60z52B z@U&R8`2IxyfEK_gTy^TysWZ23-MV+ws8M5uqN>HvR?!Hn@!TB2>%oHu_jm5xnSSQX znQ{SMjUAw|2?+^*C%_y$fZOf%Y_dEAUe~Kvukfm>s!#om1+u^U9mFIfBV#vB48!6% zS)4%Mzkk2=+_`g~R904=x^m@8bwfi#O3wBR;*a@ z4o(Ai<3U*h3gFu0hPZxCPR?Q0UtQFJ0|ySNs;c^yn4u_wWOaEF#31&os;Q!cVPKYt zbr2=Rl@vL9_Ux^N3l}Dbg@yIvU?UU?A4T1K`l`GfNqH_a4C$yoXFIYOCNglvsA5ES-`58~ovV_BQq*0Y?CIw--6ZEi5ej1Y;9O3Zi%J-1#mcAt4S|&hm(C!NWFe*pS_~Z{Pl; z1H~S+b8>PrM~@yoZqT4XLyL-vJ}4_IJLu`8khM2xFt}?`QPF=%L9Nh2+`D)0?(*f! zU%YhbQk6I0TJYW|Zc0i@f*9`?6qg$(KR^E?9K)oGCo5YlmNyFv3wOI6Px7E-Zf76(^)4^DyI1F9V#AOA-jzg&OFFLGykh?S-EQ3wjAnXn> z#F4EMF(hM(qA2b>)1bXUtGIvv{(tc3&!Z{z88T$Z@Z#d)|G^!pGV2aJt|(Z*?Xk7o z@q3)}AV!$d6Tc@tFc;>;+|YsR#Dgyd&ytdog4Wj77Mj!R0yu)6x_$fh zO(ZO*PMs>FKtp>DZFmX@iOFPo1@Cf29#_Ifyue(X-{YPK;Hm{N2fqHGK!Uj`uVBfR z1i-g$-TE5w1S$5GEnD8feO+`}7RtkYU_G?)#YJ0?q@xIbpv83l@&pLQN>4Ei z?12LZ-ru-!<4SH}cnPe}6D9J3ix)3enoOo=NlA(wQBhHY-~kA5GiJ<~gHPU6RN)0t zULU}ih*cmw{#aX<{17^$QLUXld-h9Ufr6_WupJ0Ixhk*X;+O-`ft%AdL5$h6XV0S~Isz>Ooi^95U8`HSZe2=sb@ds4>w>c6r)a6A-V!$D^!=$97=c$BE-|uZUTb{{e zO7S#yV`JlWRFEnvD*g@7AY`h-%(J8}5Tz@?`&z=LLS|fC+@D5{961`m$l5n=-u$Yu zvGHpFudA!OQeR*HfA9}?A6%YD$xDxr(g3ghiHfMusAoM+19!jL^7R8I6;bql@O+{o zv&0DXIxW$mc5-s^YAwZew}?Dx5fx;$iu`MR;#Tx@rWBJXt}i8QPc?#W`O_7naAl&x z-hQGIv_Di>>SGBzD8VoGv4s7*3R;1RL4eUy!j`&`$le}OzCCT)wCD2j@=Eb>!`Bja zcM*dxQ9ms$&4L=dNNo+A->0Ug{^