local respawnDistance = CreateConVar( "gc_respawndistance", "75", FCVAR_ARCHIVE, "Distance a map item may move from its spawn before it is returned" ) local respawnDelay = CreateConVar( "gc_itemrespawntime", "1", FCVAR_ARCHIVE, "Seconds before a collected or displaced map item respawns" ) local trackedItems = {} local suppressRespawns = false -- Sandbox defaults this to 9999, overriding every Source ammo type's intended -- carry limit. Coop uses the original per-ammo limits instead. local maxAmmoOverride = GetConVar("gmod_maxammo") if maxAmmoOverride and maxAmmoOverride:GetInt() > 0 then RunConsoleCommand("gmod_maxammo", "0") end local function ClampPlayerAmmo(ply) for ammoType = 1, 255 do local maximum = game.GetAmmoMax(ammoType) if maximum and maximum >= 0 and ply:GetAmmoCount(ammoType) > maximum then ply:SetAmmo(maximum, ammoType) end end end -- Also repairs reserves accumulated while the sandbox-wide 9999 override was -- active. Unknown and explicitly infinite ammo types are left unchanged. timer.Simple(0, function() for _, ply in ipairs(player.GetAll()) do if IsValid(ply) then ClampPlayerAmmo(ply) end end end) -- These use the item_ prefix but are map machinery or containers rather than -- touch pickups. Ammo crates are already infinite; the others may be removed -- deliberately by Hammer I/O and must not be recreated as loose supplies. local persistentItemClasses = { item_ammo_crate = true, item_dynamic_resupply = true, item_healthcharger = true, item_item_crate = true, item_suitcharger = true } local itemAmmoTypes = { item_ammo_357 = "357", item_ammo_357_large = "357", item_ammo_ar2 = "AR2", item_ammo_ar2_altfire = "AR2AltFire", item_ammo_ar2_large = "AR2", item_ammo_crossbow = "XBowBolt", item_ammo_pistol = "Pistol", item_ammo_pistol_large = "Pistol", item_ammo_smg1 = "SMG1", item_ammo_smg1_grenade = "SMG1_Grenade", item_ammo_smg1_large = "SMG1", item_box_buckshot = "Buckshot", item_rpg_round = "RPG_Round" } -- These weapons store their ammunition directly on the player rather than in -- a clip, so Clip1() cannot tell us how much a duplicate pickup is worth. local cliplessWeaponAmmo = { weapon_frag = 1, weapon_rpg = 1, weapon_slam = 1 } local function CanReceiveAmmo(ply, ammoType) if ammoType == nil or ammoType < 0 then return false end local maximum = game.GetAmmoMax(ammoType) -- Unknown/custom ammo types are left to their own pickup implementation. if maximum == nil or maximum < 0 then return true end return ply:GetAmmoCount(ammoType) < maximum end local function IsRespawnable(ent) if not IsValid(ent) then return false end local className = ent:GetClass() if persistentItemClasses[className] then return false end return string.StartWith(className, "item_") or string.StartWith(className, "weapon_") end local function IsMapWorldPickup(ent) if not IsRespawnable(ent) then return false end if ent:CreatedByMap() or ent.CustomCoopRespawnTemplate then return true end -- point_template and other Hammer spawners create new entities at runtime, -- so GMod reports CreatedByMap() as false for them. Their mapper-assigned -- targetname survives, which lets us include them without also making -- player-spawned or anonymous NPC-dropped weapons permanently respawn. if IsValid(ent:GetOwner()) or IsValid(ent:GetParent()) or IsValid(ent:GetCreator()) then return false end return ent:GetName() ~= "" end local function CaptureTemplate(ent) local bodygroups = {} for _, bodygroup in ipairs(ent:GetBodyGroups() or {}) do bodygroups[bodygroup.id] = ent:GetBodygroup(bodygroup.id) end local isWeapon = ent:IsWeapon() return { className = ent:GetClass(), position = ent:GetPos(), angles = ent:GetAngles(), model = ent:GetModel(), skin = ent:GetSkin(), material = ent:GetMaterial(), color = ent:GetColor(), collisionGroup = ent:GetCollisionGroup(), name = ent:GetName(), keyValues = ent:GetKeyValues(), bodygroups = bodygroups, clip1 = isWeapon and ent:Clip1() or nil, clip2 = isWeapon and ent:Clip2() or nil, homeSettled = false, stableSince = nil } end local TrackItem local RespawnItem local function ScheduleRespawn(template) if suppressRespawns then return end timer.Simple(math.max(respawnDelay:GetFloat(), 0), function() if suppressRespawns then return end RespawnItem(template) end) end TrackItem = function(ent, template) if not IsValid(ent) or trackedItems[ent] then return end template = template or ent.CustomCoopRespawnTemplate or CaptureTemplate(ent) trackedItems[ent] = template -- Lua entity fields survive script auto-refresh. Keeping the template on -- the entity lets a fresh copy of this file recover its original spawn. ent.CustomCoopRespawnTemplate = template ent:CallOnRemove("CustomCoopRespawnItem", function(removed) local savedTemplate = trackedItems[removed] trackedItems[removed] = nil if savedTemplate then ScheduleRespawn(savedTemplate) end end) end RespawnItem = function(template) local ent = ents.Create(template.className) if not IsValid(ent) then ErrorNoHalt("[custom_coop] Could not respawn " .. template.className .. "; retrying in 5 seconds\n") timer.Simple(5, function() if not suppressRespawns then RespawnItem(template) end end) return end -- Restore mapper-provided properties before Spawn. Position, angles and model -- are restored explicitly because GetKeyValues does not always include them. for key, value in pairs(template.keyValues or {}) do if key ~= "classname" and key ~= "origin" and key ~= "angles" and key ~= "hammerid" then ent:SetKeyValue(key, tostring(value)) end end ent:SetPos(template.position + Vector(0, 0, 3)) ent:SetAngles(template.angles) if template.model and template.model ~= "" then ent:SetModel(template.model) end if template.name and template.name ~= "" then ent:SetName(template.name) end ent:Spawn() ent:Activate() if not IsValid(ent) then ScheduleRespawn(template) return end -- Spawn normally fills these from the weapon script. Restore the captured -- values as well so mapper-specified pickup quantities survive recreation. if template.clip1 ~= nil then ent:SetClip1(template.clip1) end if template.clip2 ~= nil then ent:SetClip2(template.clip2) end ent:SetSkin(template.skin or 0) ent:SetMaterial(template.material or "") ent:SetColor(template.color or color_white) ent:SetCollisionGroup(template.collisionGroup or COLLISION_GROUP_NONE) for id, value in pairs(template.bodygroups or {}) do ent:SetBodygroup(id, value) end TrackItem(ent, template) ent:EmitSound("items/suitchargeok1.wav", 60, 100, 0.35) end hook.Add("OnEntityCreated", "CustomCoopTrackMapItems", function(ent) -- OnEntityCreated fires before keyvalues and map-created state are ready. timer.Simple(0, function() if IsMapWorldPickup(ent) then TrackItem(ent) end end) end) local function TrackExistingItems() for _, ent in ipairs(ents.GetAll()) do if IsMapWorldPickup(ent) then TrackItem(ent) end end end -- InitPostEntity handles a normal map load; the zero-delay scan handles GMod's -- script auto-refresh, where existing entities do not fire OnEntityCreated. hook.Add("InitPostEntity", "CustomCoopTrackExistingMapItems", TrackExistingItems) timer.Simple(0, TrackExistingItems) hook.Add("WeaponEquip", "CustomCoopRespawnCollectedWeapon", function(weapon) local template = trackedItems[weapon] if not template then return end -- A newly acquired weapon becomes the player's inventory entity and is not -- removed. Detach it and immediately start a new respawn cycle for its slot. trackedItems[weapon] = nil weapon:RemoveCallOnRemove("CustomCoopRespawnItem") ScheduleRespawn(template) end) -- Duplicate HL2 weapons do not transfer ammo consistently in GMod. Handle -- tracked map weapons here so useful ammo is transferred exactly once, while -- a full player leaves the pickup available and silent for someone else. hook.Add("PlayerCanPickupWeapon", "CustomCoopIgnoreFullDuplicateWeapons", function(ply, weapon) if not ply:HasWeapon(weapon:GetClass()) then return end local primaryAmmoType = weapon:GetPrimaryAmmoType() local secondaryAmmoType = weapon:GetSecondaryAmmoType() -- Some map logic creates weapons dynamically, so they are not part of the -- map-entity tracker. A second copy of an ammo-less weapon is still always -- useless and should never produce another pickup event. if primaryAmmoType < 0 and secondaryAmmoType < 0 then return false end if not trackedItems[weapon] then return end local primaryAmount = math.max(weapon:Clip1(), 0) if primaryAmount == 0 then primaryAmount = cliplessWeaponAmmo[weapon:GetClass()] or 0 end local given = 0 if primaryAmmoType >= 0 and primaryAmount > 0 then given = given + (ply:GiveAmmo(primaryAmount, primaryAmmoType) or 0) end local secondaryAmount = math.max(weapon:Clip2(), 0) if secondaryAmmoType >= 0 and secondaryAmount > 0 then given = given + (ply:GiveAmmo(secondaryAmount, secondaryAmmoType) or 0) end -- Handle the duplicate ourselves. The native path is inconsistent for -- HL2 weapons under GMod: it may drain or hide the entity without giving -- usable reserve ammo. Removing only after GiveAmmo succeeds also makes a -- full pickup silent and leaves it available to another player. if given > 0 then SafeRemoveEntityDelayed(weapon, 0) end return false end) -- Stock ammo items normally reject a full player themselves. Checking here as -- well avoids pickup effects from engine branches or addons that remove the -- item even when GiveAmmo accepted zero rounds. hook.Add("PlayerCanPickupItem", "CustomCoopIgnoreFullAmmoItems", function(ply, item) if not trackedItems[item] then return end local ammoName = itemAmmoTypes[item:GetClass()] if not ammoName then return end local ammoType = game.GetAmmoID(ammoName) if not CanReceiveAmmo(ply, ammoType) then return false end end) -- Prevent +use from dragging a map pickup away. Touch pickup still works, and -- duplicate weapons are left to the engine's EquipAmmo path so they correctly -- replenish reserve ammo and disappear only when ammo was actually accepted. hook.Add("AllowPlayerPickup", "CustomCoopProtectMapItems", function(_, ent) if trackedItems[ent] then return false end end) timer.Create("CustomCoopReturnDisplacedItems", 1, 0, function() local maxDistance = math.max(respawnDistance:GetFloat(), 0) local maxDistanceSquared = maxDistance * maxDistance local now = CurTime() for ent, template in pairs(trackedItems) do if not IsValid(ent) then trackedItems[ent] = nil elseif not IsValid(ent:GetParent()) then if not template.homeSettled then local speed = ent:GetVelocity():Length() if ent:IsOnGround() or speed <= 5 then template.stableSince = template.stableSince or now if ent:IsOnGround() or now - template.stableSince >= 1 then -- Some older coop maps deliberately create pickups high -- above their destination. Their first stable landing is -- the intended slot; gravity during that fall is not a -- displacement that should trigger another respawn. template.position = ent:GetPos() template.angles = ent:GetAngles() template.homeSettled = true template.stableSince = nil end else template.stableSince = nil end elseif ent:GetPos():DistToSqr(template.position) > maxDistanceSquared then SafeRemoveEntityDelayed(ent, 0) end end end end) hook.Add("PlayerSpawn", "CustomCoopKeepWeaponsOnDeath", function(ply) timer.Simple(0, function() if IsValid(ply) then ply:ShouldDropWeapon(false) end end) end) hook.Add("PreCleanupMap", "CustomCoopPauseItemRespawns", function() suppressRespawns = true trackedItems = {} end) hook.Add("PostCleanupMap", "CustomCoopResumeItemRespawns", function() suppressRespawns = false end) hook.Add("ShutDown", "CustomCoopStopItemRespawns", function() suppressRespawns = true end)