Fix coop item respawns and map rotation

This commit is contained in:
2026-09-06 14:38:38 +02:00
parent 8c4b0a33ff
commit 0b94cbb081
6 changed files with 335 additions and 195 deletions
+1
View File
@@ -14,5 +14,6 @@ include( "dynamicmovement.lua" )
include( "mapperstuff.lua" )
include( "noheadcrabs.lua" )
include( "shared.lua" )
include( "maprotation.lua" )
resource.AddFile( "sound/weapons/universal/pap.wav" )
+33 -4
View File
@@ -1,5 +1,4 @@
function GetRandomLevel()
local myTable = {
COOP_LEVELS = {
"syn_deadsimple",
"Poison_Coop_RE1_Beta1",
"oc_systems2B-2",
@@ -402,6 +401,36 @@ function GetRandomLevel()
"cl_coop_breakable",
"cl_coop_breakable_b2",
"ad_coop_mara_final"
}
return myTable[ math.random( #myTable ) ]
}
local function GetInstalledCoopLevels()
local installedMaps = {}
local availableLevels = {}
for _, fileName in ipairs(file.Find("maps/*.bsp", "GAME")) do
installedMaps[string.lower(string.StripExtension(fileName))] = true
end
for _, levelName in ipairs(COOP_LEVELS) do
local normalizedName = string.lower(levelName)
if installedMaps[normalizedName] and not table.HasValue(availableLevels, normalizedName) then
table.insert(availableLevels, normalizedName)
end
end
return availableLevels
end
function GetRandomLevel(excludeLevel)
local candidates = {}
local excluded = string.lower(excludeLevel or game.GetMap() or "")
for _, levelName in ipairs(GetInstalledCoopLevels()) do
if levelName ~= excluded then
table.insert(candidates, levelName)
end
end
if #candidates == 0 then return nil end
return candidates[math.random(#candidates)]
end
+13 -2
View File
@@ -23,6 +23,18 @@ function RemoveSpeedMods()
end
function MapChange(mapname)
mapname = string.lower(tostring(mapname or ""))
if not file.Exists("maps/" .. mapname .. ".bsp", "GAME") then
local fallback = GetRandomLevel()
if not fallback then
ErrorNoHalt("[custom_coop] Cannot change map: '" .. mapname .. "' is unavailable and no fallback is installed\n")
return false
end
ErrorNoHalt("[custom_coop] Map '" .. mapname .. "' is unavailable; using " .. fallback .. " instead\n")
mapname = fallback
end
file.Write("weptranslevel.txt",string.lower(mapname))
file.Write("weptransplayers.txt","")
file.Write("weptransweps.txt","")
@@ -71,8 +83,7 @@ function MapChange(mapname)
end
end
timer.Simple( 2, function() RunConsoleCommand("changelevel",string.lower(mapname)) end )
timer.Simple( 4, function() PrintMessage( HUD_PRINTTALK,"Next map not found! Download the next map!" ) end )
timer.Simple( 7, function() RunConsoleCommand("disconnect") end )
return true
end
function LoadWeapons()
+108
View File
@@ -0,0 +1,108 @@
local changeDelay = CreateConVar(
"gc_mapchangedelay",
"8",
FCVAR_ARCHIVE,
"Seconds between map completion and loading the next coop map"
)
local maxMapTime = CreateConVar(
"gc_mapmaxtime",
"3600",
FCVAR_ARCHIVE,
"Maximum map time in seconds before rotating; 0 disables the time limit"
)
local changeQueued = false
local function MapExists(mapName)
if not mapName or mapName == "" then return false end
local wanted = string.lower(string.StripExtension(mapName))
for _, fileName in ipairs(file.Find("maps/*.bsp", "GAME")) do
if string.lower(string.StripExtension(fileName)) == wanted then
return true
end
end
return false
end
local function SetFreshNextLevel()
local nextLevel = GetConVar("nextlevel"):GetString()
if nextLevel == "" or not MapExists(nextLevel) or string.lower(nextLevel) == string.lower(game.GetMap()) then
nextLevel = NextRandomLevel()
end
return nextLevel
end
function QueueCoopMapChange(reason, requestedMap)
if changeQueued then return end
local destination = requestedMap and string.lower(requestedMap) or nil
if not MapExists(destination) or destination == string.lower(game.GetMap()) then
destination = SetFreshNextLevel()
end
if not destination then
ErrorNoHalt("[custom_coop] Map completed, but no installed destination map is available\n")
return
end
changeQueued = true
local delay = math.max(changeDelay:GetFloat(), 0)
PrintMessage(HUD_PRINTTALK, "Map complete! Loading " .. destination .. " in " .. math.ceil(delay) .. " seconds.")
print("[custom_coop] Map change queued by " .. (reason or "unknown") .. ": " .. destination)
timer.Create("CustomCoopChangeCompletedMap", delay, 1, function()
RunConsoleCommand("changelevel", destination)
end)
end
hook.Add("AcceptInput", "CustomCoopDetectMapCompletion", function(ent, inputName, _, _, value)
if not IsValid(ent) then return end
local className = string.lower(ent:GetClass())
local input = string.lower(inputName or "")
if className == "game_end" and input == "endgame" then
QueueCoopMapChange("game_end")
return true
end
if className ~= "point_servercommand" or input ~= "command" then return end
local command = string.lower(string.Trim(tostring(value or "")))
local requestedMap = string.match(command, "^changelevel%s+[%\"]?([%w_%-%[%]!]+)")
or string.match(command, "^map%s+[%\"]?([%w_%-%[%]!]+)")
if requestedMap and (not MapExists(requestedMap) or string.lower(requestedMap) == string.lower(game.GetMap())) then
QueueCoopMapChange("point_servercommand", requestedMap)
return true
end
end)
-- Repair old trigger_changelevel/point_changelevel entities that refer to a map
-- which is not mounted. Valid campaign destinations remain untouched.
hook.Add("EntityKeyValue", "CustomCoopRepairChangelevelTargets", function(ent, key, value)
local className = string.lower(ent:GetClass())
if className ~= "trigger_changelevel" and className ~= "point_changelevel" then return end
if string.lower(key or "") ~= "map" then return end
if MapExists(value) and string.lower(value) ~= string.lower(game.GetMap()) then return end
local replacement = GetRandomLevel()
if replacement then
print("[custom_coop] Replacing unavailable changelevel target '" .. tostring(value) .. "' with " .. replacement)
return replacement
end
end)
hook.Add("InitPostEntity", "CustomCoopStartMapTimeLimit", function()
SetFreshNextLevel()
local limit = maxMapTime:GetFloat()
if limit <= 0 then return end
timer.Create("CustomCoopMapTimeLimit", limit, 1, function()
QueueCoopMapChange("time limit")
end)
end)
+28 -19
View File
@@ -7,29 +7,38 @@ DeriveGamemode("sandbox")
include( "levels.lua" )
function ChangeRandomLevel(ply,com,arg)
local randomLevel = GetRandomLevel()
print("Changing to random level " .. randomLevel)
RunConsoleCommand( "changelevel", randomLevel )
end
function NextRandomLevel(ply,com,arg)
local randomLevel = GetRandomLevel()
print("Setting nextlevel to random " .. randomLevel)
RunConsoleCommand( "nextlevel", randomLevel )
end
if (SERVER) then
function ChangeRandomLevel()
local randomLevel = GetRandomLevel()
if not randomLevel then
ErrorNoHalt("[custom_coop] No other installed coop map was found in levels.lua\n")
return false
end
print("[custom_coop] Changing to random level " .. randomLevel)
RunConsoleCommand("changelevel", randomLevel)
return true
end
function NextRandomLevel()
local randomLevel = GetRandomLevel()
if not randomLevel then
ErrorNoHalt("[custom_coop] Cannot set nextlevel: no other installed coop map was found\n")
return nil
end
print("[custom_coop] Setting nextlevel to " .. randomLevel)
RunConsoleCommand("nextlevel", randomLevel)
return randomLevel
end
concommand.Add("randomlevel", ChangeRandomLevel)
concommand.Add("randomnextlevel", NextRandomLevel)
end
hook.Add( "Initialize", "SetupRandomLevels", function ()
print("hatschi2")
timer.Simple( 3600, ChangeRandomLevel )
NextRandomLevel()
timer.Simple( 0, NextRandomLevel )
end)
hook.Add("Initialize", "SetupRandomLevels", function()
timer.Simple(0, NextRandomLevel)
end)
end
-- timer.Simple(
-- 1,
+152 -170
View File
@@ -1,191 +1,173 @@
CreateConVar("gc_respawndistance", 75, SERVER_CAN_EXECUTE, "Item distance to map origin to trigger a respawn")
local respawnDistance = CreateConVar(
"gc_respawndistance",
"75",
FCVAR_ARCHIVE,
"Distance a map item may move from its spawn before it is returned"
)
hook.Add("PlayerSpawn", "SpawningInitializeAmmo", function(ply)
timer.Simple(0, function()
ply:ShouldDropWeapon(false)
local respawnDelay = CreateConVar(
"gc_itemrespawntime",
"1",
FCVAR_ARCHIVE,
"Seconds before a collected or displaced map item respawns"
)
local trackedItems = {}
local suppressRespawns = false
local function IsRespawnable(ent)
if not IsValid(ent) then return false end
local className = ent:GetClass()
return string.StartWith(className, "item_") or string.StartWith(className, "weapon_")
end
local function CaptureTemplate(ent)
local bodygroups = {}
for _, bodygroup in ipairs(ent:GetBodyGroups() or {}) do
bodygroups[bodygroup.id] = ent:GetBodygroup(bodygroup.id)
end
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
}
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)
end
hook.Add( "OnEntityCreated", "Respawn items", function( ent )
if not ent:IsValid() then
--print("Ignoring invalid item")
return
end
TrackItem = function(ent, template)
if not IsValid(ent) or trackedItems[ent] then return end
local entityClass = ent:GetClass()
template = template or CaptureTemplate(ent)
trackedItems[ent] = template
if not string.StartWith(entityClass, "item_") and not string.StartWith(entityClass, "weapon_") then
--print("Ignoring not-item " .. entityClass)
return
end
ent:CallOnRemove("CustomCoopRespawnItem", function(removed)
local savedTemplate = trackedItems[removed]
trackedItems[removed] = nil
timer.Simple(0, function()
if not ent:IsValid() then
-- print("Ignoring invalid item " .. entityClass .. " (from timer)")
return
if savedTemplate then
ScheduleRespawn(savedTemplate)
end
end)
end
local entityClass = ent:GetClass()
local entityPos = ent:GetPos()
local entityAngles = ent:GetAngles()
local entityModel = ent:GetModel()
local entityCollisionGroup = ent:GetCollisionGroup()
local entityCreatedByMap = ent:CreatedByMap()
ent:SetNWBool("blockPickup", true)
ent:SetNWVector("respawnPos", entityPos)
ent:SetNWAngle("respawnAngle", entityAngle)
ent:CallOnRemove("Respawn", function(ent)
-- print("item " .. entityClass .. " removed!")
timer.Simple(1, function()
if (entityCreatedByMap) then
entityPos = entityPos + Vector(0, 0, 3)
end
local ent = ents.Create(entityClass)
ent:SetPos(entityPos)
ent:SetAngles(entityAngles)
ent:SetModel(entityModel)
ent:SetCollisionGroup(entityCollisionGroup)
ent:Spawn()
ent:EmitSound("items/suitchargeok1.wav")
-- print("item " .. entityClass .. " recreated!")
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
local callbackId = ent:AddCallback( "OnAngleChange", function( entity, newangle )
local distance = entity:GetPos():Distance(entityPos)
if (distance < GetConVarNumber("gc_respawndistance")) then
return
end
local callbackId = entity:GetNWInt("callbackId")
entity:RemoveCallback( "OnAngleChange", callbackId )
-- print("item " .. entityClass .. " distance: " .. distance .. " callbackId: " .. callbackId)
timer.Simple(1, function()
if not entity:IsValid() then
return
end
entity:Remove()
end)
end )
ent:SetNWInt("callbackId", callbackId)
-- print("Map item processed: " .. ent:GetClass() .. " pos: " .. entityPos.x .. "," .. entityPos.y .. "," .. entityPos.z)
end)
end )
hook.Add("PlayerCanPickupWeapon", "DontPickupIfAlreadyFull", function (ply, weapon)
local className = weapon:GetClass()
for k, v in pairs(ply:GetWeapons()) do
if (v:GetClass() == className) then
local primaryAmmoType = weapon:GetPrimaryAmmoType()
local secondaryAmmoType = weapon:GetSecondaryAmmoType()
local primaryAmmoCount = ply:GetAmmoCount(primaryAmmoType)
local secondaryAmmoCount = ply:GetAmmoCount(secondaryAmmoType)
local vClip1 = v:Clip1()
local vClip2 = v:Clip2()
local vMaxClip1 = v:GetMaxClip1()
local vMaxClip2 = v:GetMaxClip2()
if ply:KeyDown(IN_USE) then
ply:ChatPrint(className .. ": vClip1:" .. vClip1 .. " maxClip1:" .. vMaxClip1 .. " vClip2:" .. vClip2.. " maxClip2:" .. vMaxClip2 ..
" playerClip1:" .. primaryAmmoCount .. " playerClip2:" .. secondaryAmmoCount ..
" wClip1:" .. weapon:Clip1() .. " wClip2:" .. weapon:Clip2())
end
local blacklist = {
"weapon_crowbar",
"weapon_fists",
"weapon_frag",
"weapon_rpg"
}
local whitelist = {
"weapon_pistol",
"weapon_357"
}
if (table.HasValue(blacklist, className)) then
local gave = false
if (vMaxClip1 < 0 and primaryAmmoCount < 15) then
local amountToGive = 15 - primaryAmmoCount
ply:GiveAmmo(amountToGive, primaryAmmoType)
--ply:ChatPrint(className .. " give1 " .. amountToGive .. " primary (" .. vClip1 .. "/" .. primaryAmmoCount .. "/" .. vMaxClip1 .. ")")
gave = true
end
if (vMaxClip2 < 0 and secondaryAmmoCount < 15) then
local amountToGive = 15 - secondaryAmmoCount
ply:GiveAmmo(amountToGive, secondaryAmmoType)
--ply:ChatPrint(className .. " give1 " .. amountToGive .. " secondary (" .. vClip2 .. "/" .. secondaryAmmoCount .. "/" .. vMaxClip2 .. ")")
gave = true
end
if gave then
return table.HasValue(whitelist, className)
end
end
if primaryAmmoCount >= vMaxClip1 and secondaryAmmoCount >= vMaxClip2 then
return false
else
if primaryAmmoCount < vMaxClip1 then
ply:GiveAmmo(math.min(weapon:Clip1(), vMaxClip1 - primaryAmmoCount), primaryAmmoType)
--ply:ChatPrint(className .. " give2 " .. math.min(weapon:Clip1(), vMaxClip1 - primaryAmmoCount) .. " primary")
end
if secondaryAmmoCount < vMaxClip2 then
ply:GiveAmmo(math.min(weapon:Clip2(), vMaxClip2 - secondaryAmmoCount), secondaryAmmoType)
--ply:ChatPrint(className .. " give2 " .. math.min(weapon:Clip2(), vMaxClip2 - secondaryAmmoCount) .. " secondary")
end
return table.HasValue(whitelist, className)
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
return true
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()
hook.Add( "WeaponEquip", "RespawnOnCollected", function(weapon, ply)
-- print("WeaponEquip " .. weapon:GetClass())
if not IsValid(ent) then
ScheduleRespawn(template)
return
end
local entityClass = weapon:GetClass()
local entityPos = weapon:GetNWVector("respawnPos")
local entityAngles = weapon:GetNWAngle("respawnAngle")
local entityModel = weapon:GetModel()
local entityCollisionGroup = weapon:GetCollisionGroup()
local entityCreatedByMap = weapon:CreatedByMap()
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
timer.Simple(1, function()
if (entityCreatedByMap) then
entityPos = entityPos + Vector(0, 0, 3)
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 IsRespawnable(ent) and ent:CreatedByMap() then
TrackItem(ent)
end
local ent = ents.Create(entityClass)
ent:SetPos(entityPos)
ent:SetAngles(entityAngles)
ent:SetModel(entityModel)
ent:SetCollisionGroup(entityCollisionGroup)
ent:Spawn()
ent:EmitSound("items/suitchargeok1.wav")
-- print("item " .. entityClass .. " recreated!")
end)
--if (weapon:GetClass() == "weapon_crowbar") then
-- if (ply:HasWeapon("weapon_fists")) then
-- ply:StripWeapon("weapon_fists")
-- end
--end
end)
hook.Add("AllowPlayerPickup", "some_unique_name", function (ply, ent)
return not ent:GetNWBool("blockPickup")
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)
-- 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
for ent, template in pairs(trackedItems) do
if not IsValid(ent) then
trackedItems[ent] = nil
elseif not IsValid(ent:GetParent()) and ent:GetPos():DistToSqr(template.position) > maxDistanceSquared then
SafeRemoveEntityDelayed(ent, 0)
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)