From 0b94cbb0810e6139062efcca57ba5cee23f72757 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sun, 6 Sep 2026 14:38:38 +0200 Subject: [PATCH] Fix coop item respawns and map rotation --- gamemode/init.lua | 1 + gamemode/levels.lua | 37 ++++- gamemode/mapperstuff.lua | 15 +- gamemode/maprotation.lua | 108 +++++++++++++ gamemode/shared.lua | 47 +++--- gamemode/weaponsystem.lua | 322 ++++++++++++++++++-------------------- 6 files changed, 335 insertions(+), 195 deletions(-) create mode 100644 gamemode/maprotation.lua diff --git a/gamemode/init.lua b/gamemode/init.lua index c1bfbdd..ce53c3d 100644 --- a/gamemode/init.lua +++ b/gamemode/init.lua @@ -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" ) diff --git a/gamemode/levels.lua b/gamemode/levels.lua index 0631225..f47bdd5 100644 --- a/gamemode/levels.lua +++ b/gamemode/levels.lua @@ -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 diff --git a/gamemode/mapperstuff.lua b/gamemode/mapperstuff.lua index 8ba3e29..9bf1a6c 100644 --- a/gamemode/mapperstuff.lua +++ b/gamemode/mapperstuff.lua @@ -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() diff --git a/gamemode/maprotation.lua b/gamemode/maprotation.lua new file mode 100644 index 0000000..943c572 --- /dev/null +++ b/gamemode/maprotation.lua @@ -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) diff --git a/gamemode/shared.lua b/gamemode/shared.lua index 1353692..43c632b 100644 --- a/gamemode/shared.lua +++ b/gamemode/shared.lua @@ -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, diff --git a/gamemode/weaponsystem.lua b/gamemode/weaponsystem.lua index 00a110a..de508c7 100644 --- a/gamemode/weaponsystem.lua +++ b/gamemode/weaponsystem.lua @@ -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)