Files

363 lines
13 KiB
Lua

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)