363 lines
13 KiB
Lua
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)
|