Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 182 additions & 53 deletions LuaRules/Gadgets/api_zombie_helper.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,62 +27,84 @@ VFS.Include("LuaRules/Configs/CAI/accessory/targetReachableTester.lua")
-- unsure if these are different, differentiating between gaia and "zombie" team somehow may be good?
local GaiaTeamID = Spring.GetGaiaTeamID()
local GaiaAllyTeamID = select(6, Spring.GetTeamInfo(GaiaTeamID, false))
local WARNING_TIME = 5 -- set to 5 for sounds and such to sync well.
local random = math.random
local floor = math.floor

local mapWidth
local mapHeight

local gameframe = 0

local NonZombies = {
["asteroid"] = true,
}

local CEG_SPAWN = [[zombie]]
local REZ_SOUND = "sounds/misc/resurrect.wav"
local ZOMBIE_SOUNDS = {
"sounds/misc/zombie_1.wav",
"sounds/misc/zombie_2.wav",
"sounds/misc/zombie_3.wav",
}

local resurrectingFeatures = {} -- contains inital gameframe, base rez time, frame rez should complete and a callback function if someone wants to reuse the functionality


-- Zombie resurrect

-- unsure if necessary, seems like just an extra failsafe

-- Get Feature Defname and facing
local function GetFeatureResurrectData(featureID)
local featureDefName, facing = Spring.GetFeatureResurrect(featureID)
if featureDefName == "" then
local resDefName, facing = Spring.GetFeatureResurrect(featureID)
if resDefName == "" then
local featureDef = FeatureDefs[Spring.GetFeatureDefID(featureID)]
local featureName = featureDef.name or ""
if featureDef.resurrectable == 1 then
featureDefName = featureName:gsub('(.*)_.*', '%1') --filter out _dead
resDefName = featureName:gsub('(.*)_.*', '%1') --filter out _dead
facing = facing or 0
end
end
return featureDefName, facing
return resDefName, facing
end

local function TurnFeatureIntoUnit(featureID,teamID,reclaimPercentHealth)

local featureDefName,facing = GetFeatureResurrectData(featureID)
local x, y, z = Spring.GetFeaturePosition(featureID)

local unitID = Spring.CreateUnit(featureDefName, x, y, z, facing, teamID)

-- Unit and Wreck exist both
-- could let there be a function passed to this function, that returns the Unit ID for transfer of values

if (unitID) then
gadgetHandler:NotifyUnitCreatedByMechanic(unitID, false, "zombies")
local size = UnitDefNames[featureDefName].xsize
Spring.SpawnCEG("resurrect", x, y, z, 0, 0, 0, size)
Spring.GiveOrderToUnit(unitID, CMD.FIRE_STATE, 2, 0)
GG.PlayFogHiddenSound(REZ_SOUND, 12, x, y, z)

if reclaimPercentHealth then
local currentMetal, maxMetal = Spring.GetFeatureResources(featureID)
if currentMetal and maxMetal and (maxMetal > 0) then
local health = Spring.GetUnitHealth(unitID)
if health then
Spring.SetUnitHealth(unitID, health*(currentMetal/maxMetal))
end
end
end
end
-- Zombie resurrect
-- Turns a feature into a unit if applicable and returns the unitID
-- Instead of using the wreck, can receive a UnitDef Or UnitDefID to spawn a different unit
local function TurnFeatureIntoUnit(featureID,teamID,unitDefID)
local resDefName,facing = GetFeatureResurrectData(featureID)
local x, y, z = Spring.GetFeaturePosition(featureID)
local size = 3
if unitDefID then
resDefName = unitDefID
size = UnitDefs[resDefName].xsize
else
size = UnitDefNames[resDefName].xsize
end

Spring.DestroyFeature(featureID)
return unitID
local unitID = Spring.CreateUnit(resDefName, x, y, z, facing, teamID)

if not (unitID) then
return nil
end
gadgetHandler:NotifyUnitCreatedByMechanic(unitID, false, "zombies") --TODO should this be here? is this used anywhere?
Spring.SpawnCEG("resurrect", x, y, z, 0, 0, 0, size)
Spring.GiveOrderToUnit(unitID, CMD.FIRE_STATE, 2, 0)
GG.PlayFogHiddenSound(REZ_SOUND, 12, x, y, z)
return unitID
end

-- Works on non zombie units too.
local function SetHealthToReclaimPercent(featureID, unitID)
local currentMetal, maxMetal = Spring.GetFeatureResources(featureID)
if currentMetal and maxMetal and (maxMetal > 0) then
local health = Spring.GetUnitHealth(unitID)
if health then
Spring.SetUnitHealth(unitID, math.ceil(health*(currentMetal/maxMetal)))
end
end
end

-- Sets the zombie specific speed multiplier. Works on non zombie units too.
local function SetZombieSpeedMult(unitID,speedMult)
if type(speedMult) ~= 'number' or speedMult < 0 then
error("SetZombieSpeedMult: mult must be number >= 0")
Expand All @@ -91,9 +113,7 @@ local function SetZombieSpeedMult(unitID,speedMult)
GG.UpdateUnitAttributes(unitID)
end


-- Zombie commands

local function RandomFactoryOrders(unitID, unitDefID) -- give factory something to do
if Spring.GetUnitIsDead(unitID) then
return
Expand All @@ -103,14 +123,12 @@ local function RandomFactoryOrders(unitID, unitDefID) -- give factory something
return
end
local orders = {}
for i = 1, math.random(10, 30) do
orders[#orders + 1] = {-buildopts[math.random(1, #buildopts)], 0, 0 }
for i = 1, random(10, 30) do
orders[#orders + 1] = {-buildopts[random(1, #buildopts)], 0, 0 }
end
Spring.GiveOrderArrayToUnit(unitID, orders)
end



local function GetUnitNearestAlly(unitID, range)
local best_ally
local best_dist
Expand All @@ -123,6 +141,7 @@ local function GetUnitNearestAlly(unitID, range)
if (allyID ~= unitID) and (allyTeam == GaiaTeamID) and (Spring.Utilities.getMovetype(UnitDefs[allyDefID]) ~= false) then
local ox, oy, oz = Spring.GetUnitPosition(allyID)
local dist = math.diag(x - ox, z - oz)
--TODO reinstate once engine thing is fixed, IsTargetReallyReachable enabled for testing
if IsTargetReallyReachable(unitID, ox, oy, oz, x, y, z) and ((best_dist == nil) or (dist < best_dist)) then
best_ally = allyID
best_dist = dist
Expand All @@ -132,13 +151,14 @@ local function GetUnitNearestAlly(unitID, range)
return best_ally
end

local function GiveZombiesRandomOrders(unitID)
-- Applies Random Attackmove orders and Factory commands to units.
local function SetZombieBehavior(unitID)
local unitDefID = (not Spring.GetUnitIsDead(unitID)) and Spring.GetUnitDefID(unitID)
if not unitDefID then
return
end

local rx,rz,ry
Spring.GiveOrderToUnit(unitID, CMD.MOVE_STATE, 2, 0)
local orders = {}
local near_ally
if (UnitDefs[unitDefID].canAttack) then
Expand All @@ -151,13 +171,14 @@ local function GiveZombiesRandomOrders(unitID)
end
end
local x,y,z = Spring.GetUnitPosition(unitID)
if (near_ally) and math.random(0, 5) < 4 then -- 60% chance to guard nearest ally
if (near_ally) and random(0, 5) < 4 then -- 60% chance to guard nearest ally
orders[#orders + 1] = {CMD.GUARD, {near_ally}, 0}
end
for i = 1, math.random(10, 30) do
local rx = math.random(0, mapWidth)
local rz = math.random(0, mapHeight)
for i = 1, random(10, 30) do
local rx = random(0, mapWidth)
local rz = random(0, mapHeight)
local ry = Spring.GetGroundHeight(rx,rz)
--TODO once engine is fixed and this doesnt crash readd, IsTargetReallyReachable enabled for testing
if IsTargetReallyReachable(unitID, rx, ry, rz, x, y, z) then
orders[#orders+1] = {CMD.FIGHT, {rx, ry, rz}, CMD.OPT_SHIFT}
end
Expand All @@ -169,15 +190,123 @@ local function GiveZombiesRandomOrders(unitID)
end
end

-- Adds a wreck into the zombie countdown table and returns it for further modification.
-- Use the rezFrameCallback to repurpose the system for other effects or chain into TurnFeatureIntoUnit for a revived unit with ID Callback.
-- If no callback is provided, revives the wreck as a unslowed zombie unit.
local function AddFeatureToZombieCountdown(featureID, buildpower, minRezTime, maxRezTime, rezFrameCallback)
if resurrectingFeatures[featureID] then
return resurrectingFeatures[featureID]
end
local resDefName, face = GetFeatureResurrectData(featureID)
if resDefName and face then
local ud = resDefName and UnitDefNames[resDefName]
if ud and not NonZombies[resDefName] then
local rezBaseTime = ud.metalCost / buildpower
local _,_,_,_,reclaimPercent,_ = Spring.GetFeatureResources(featureID)
if reclaimPercent ~= 1 then
rezBaseTime = rezBaseTime + rezBaseTime * (1 - reclaimPercent)
end

Comment on lines +205 to +209

@Stiofan-K Stiofan-K Sep 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Starting revive time is also based on reclaim % left in the wreck instead of assuming it is at 100%

This is new, but shouldnt change anything for base zombies as they revive on Feature creation at 100% reclaim. Seem logical and inline for how it would work for a delayed revival application however.

if minRezTime and rezBaseTime < minRezTime then
rezBaseTime = minRezTime
end

if maxRezTime and rezBaseTime > maxRezTime then
rezBaseTime = maxRezTime
end

local rezFrame = gameframe + rezBaseTime * 32

resurrectingFeatures[featureID] = {
rezInitFrame = gameframe, -- frame Feature was queued
rezBaseTime = rezBaseTime, -- base resurrect time in seconds
rezFrame = rezFrame, -- frame the callback is fired or standard resurrect is performed.
rezFrameCallback = rezFrameCallback, -- callback function fired on rezFrame
rezWarningTime = WARNING_TIME, -- warning time in seconds for base particles and sfx, set 0 to hide.
reclaimPercent = reclaimPercent, -- reclaim left in feature in % to compare and adjust reztime
}
return resurrectingFeatures[featureID]

@Stiofan-K Stiofan-K Sep 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should allow anyone to modify the base values to their liking afterwards

Unsure if there would be a better way to do things for this function outside of me assuming some base functionality/behavior as a wanted standard and then exposing the output for tweaks.

end
end
return nil
end

-- Get and modify the table as you wish.
local function GetZombieResurrectData()
return resurrectingFeatures
end

-- Reclaiming the wreck can stretch the revive time up to 2x the base time.
function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part)
if (resurrectingFeatures[featureID]) then
local rezInitFrame = resurrectingFeatures[featureID].rezInitFrame
local rezBaseTime = resurrectingFeatures[featureID].rezBaseTime
local reclaimPercent = resurrectingFeatures[featureID].reclaimPercent

reclaimPercent = reclaimPercent + part
local rezTime = rezBaseTime + rezBaseTime * (1 - reclaimPercent)
resurrectingFeatures[featureID].rezFrame = rezInitFrame + rezTime * 32

resurrectingFeatures[featureID].reclaimPercent = reclaimPercent
end
return true
Comment on lines +239 to +252

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was a bit scary to touch, but I think is functionally identical.
We count the reclaim % remaining down from 100 instead of reclaim taken up from 0.

end

function gadget:FeatureDestroyed(featureID, allyTeam)
if (resurrectingFeatures[featureID]) then
resurrectingFeatures[featureID] = nil
end
end

function gadget:GameFrame(f)
gameframe = f
if f%32 ~= 0 then
return
end
local spSpawnCEG = Spring.SpawnCEG -- putting the localization here because cannot localize in global scope since spring 97
for featureID in pairs(resurrectingFeatures) do
local rezFrame = resurrectingFeatures[featureID].rezFrame
if rezFrame <= gameframe then
if resurrectingFeatures[featureID].rezFrameCallback then
local callbackFunction = resurrectingFeatures[featureID].rezFrameCallback
resurrectingFeatures[featureID] = nil
callbackFunction(featureID)
else
local unitID = TurnFeatureIntoUnit(featureID,GaiaTeamID)
SetZombieBehavior(unitID)
SetHealthToReclaimPercent(featureID, unitID)
Spring.DestroyFeature(featureID)
end
else
local secondsTillRezCall = floor((rezFrame - f) / 32)
local rezWarningTime = resurrectingFeatures[featureID].rezWarningTime
--Spring.Echo("Time left for "..featureID..": "..secondsTillRezCall)
if secondsTillRezCall <= rezWarningTime then
local r = Spring.GetFeatureRadius(featureID)
local x, y, z = Spring.GetFeaturePosition(featureID)
spSpawnCEG(CEG_SPAWN, x, y, z, 0, 0, 0, 10 + r, 10 + r)

if secondsTillRezCall == rezWarningTime then
local z_sound = ZOMBIE_SOUNDS[random(#ZOMBIE_SOUNDS)]
GG.PlayFogHiddenSound(z_sound, 4, x, y, z)
end
end
end
end
end

function gadget:Initialize()

mapWidth = Game.mapSizeX
mapHeight = Game.mapSizeZ

GG.Zombies = {
TurnFeatureIntoUnit = TurnFeatureIntoUnit,
SetZombieSpeedMult = SetZombieSpeedMult,
SetZombieBehavior = GiveZombiesRandomOrders,
GetFeatureResurrectData = GetFeatureResurrectData
}
end
TurnFeatureIntoUnit = TurnFeatureIntoUnit,
SetZombieSpeedMult = SetZombieSpeedMult,
SetZombieBehavior = SetZombieBehavior,
SetHealthToReclaimPercent = SetHealthToReclaimPercent,
GetFeatureResurrectData = GetFeatureResurrectData,
GetZombieResurrectData = GetZombieResurrectData, -- I want to expose these for modification from outside if desired
AddFeatureToZombieCountdown = AddFeatureToZombieCountdown
}
end
26 changes: 26 additions & 0 deletions LuaRules/Gadgets/game_over.lua
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ local campaignBattleID = Spring.GetModOptions().singleplayercampaignbattleid and
local planetIndex = Spring.GetModOptions().singleplayercampaignbattleid
planetIndex = planetIndex and tonumber(planetIndex)

local zombies = tobool(modOptions.zombies) or false
local deadTeamCounter = 0

local SPARE_PLANETWARS_UNITS = false
local SPARE_REGULAR_UNITS = (modOptions.explodeondefeat == "0" or modOptions.explodeondefeat == 0)

Expand Down Expand Up @@ -191,6 +194,20 @@ local function GetTeamIsChicken(teamID)
return false
end

local function ZombieIsSecondToLastStanding(allyTeamID)
if not zombies then
return false
end
if GG.ZombiesGlobal.GetGlobalReviveAllyTeam() == allyTeamID then
if #spGetAllyTeamList() == deadTeamCounter+3 then
Spring.Echo("Zombies are defeatable")
return false
end
return true
end
return false
end

local function CountAllianceUnits(allianceID)
local teamlist = spGetTeamList(allianceID) or {}
local count = 0
Expand Down Expand Up @@ -309,6 +326,11 @@ local function DestroyAlliance(allianceID, delayLossToNextGameFrame)
alliancesToDestroy[#alliancesToDestroy + 1] = allianceID
return
end

if ZombieIsSecondToLastStanding(allianceID) then
return
end

if not destroyedAlliances[allianceID] then
destroyedAlliances[allianceID] = true
local teamList = spGetTeamList(allianceID)
Expand Down Expand Up @@ -342,6 +364,7 @@ local function DestroyAlliance(allianceID, delayLossToNextGameFrame)
EchoUIMessage("Game Over: If this is true, then please resign.")
return -- don't perform victory check
else -- kaboom
deadTeamCounter = deadTeamCounter + 1 --TODO there is likely a better way of this
if not (isMission or isScriptMission) then
local name = Spring.GetGameRulesParam("allyteam_long_name_" .. allianceID)
EchoUIMessage(name .. " has been destroyed!")
Expand Down Expand Up @@ -553,6 +576,9 @@ local function ProcessLastAlly()
if (not teamlist) then
break
end -- continue
if ZombieIsSecondToLastStanding(allyTeamID) then
break
end -- continue
local hasActiveTeam = false
local hasDroppedTeam = false

Expand Down
Loading