Files

321 lines
9.9 KiB
Lua

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 skipVoteRatio = CreateConVar(
"gc_mapskipvoteratio",
"0.6",
FCVAR_ARCHIVE,
"Fraction of connected human players required to skip the current map"
)
local nextMapVoteRatio = CreateConVar(
"gc_nextmapvoteratio",
"0.6",
FCVAR_ARCHIVE,
"Fraction of connected human players required to select the next map"
)
local skipChangeDelay = CreateConVar(
"gc_mapskipchangedelay",
"3",
FCVAR_ARCHIVE,
"Seconds between a successful player skip vote and changing map"
)
local changeQueued = false
local skipVotes = {}
local nextMapVotes = {}
local nextMapChoices = {}
local nextMapVoteFinished = false
local installedMaps
local function RefreshInstalledMaps()
installedMaps = {}
for _, fileName in ipairs(file.Find("maps/*.bsp", "GAME")) do
installedMaps[string.lower(string.StripExtension(fileName))] = true
end
end
local function MapExists(mapName)
if not mapName or mapName == "" then return false end
if not installedMaps then RefreshInstalledMaps() end
return installedMaps[string.lower(string.StripExtension(mapName))] == true
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
local function PlayerVoteID(ply)
local steamID64 = ply:SteamID64()
if steamID64 and steamID64 ~= "" then return steamID64 end
return "user:" .. tostring(ply:UserID())
end
local function RequiredVotes(ratioConVar)
local humanCount = #player.GetHumans()
local ratio = math.Clamp(ratioConVar:GetFloat(), 0.1, 1)
return math.max(math.ceil(humanCount * ratio), 1)
end
local function BuildNextMapChoices()
local candidates = {}
local currentMap = string.lower(game.GetMap())
for _, mapName in ipairs(GetInstalledCoopLevels()) do
if mapName ~= currentMap then
candidates[#candidates + 1] = mapName
end
end
for index = #candidates, 2, -1 do
local other = math.random(index)
candidates[index], candidates[other] = candidates[other], candidates[index]
end
nextMapChoices = {}
local plannedMap = SetFreshNextLevel()
if plannedMap then
nextMapChoices[1] = string.lower(plannedMap)
end
for _, mapName in ipairs(candidates) do
if #nextMapChoices >= 5 then break end
if not table.HasValue(nextMapChoices, mapName) then
nextMapChoices[#nextMapChoices + 1] = mapName
end
end
end
local function ShowNextMapChoices(ply)
if #nextMapChoices == 0 then BuildNextMapChoices() end
if #nextMapChoices == 0 then
ply:ChatPrint("[CO-OP] No alternative installed co-op map is available.")
return
end
ply:ChatPrint("[CO-OP] Vote for the map that should follow this one:")
for index, mapName in ipairs(nextMapChoices) do
ply:ChatPrint(string.format("[CO-OP] %d. %s", index, mapName))
end
ply:ChatPrint("[CO-OP] Type !votenext <number>. Type !skip if this map is stuck.")
end
local function ResolveNextMapChoice(requestedChoice)
local choiceNumber = tonumber(requestedChoice)
if choiceNumber then
return nextMapChoices[math.floor(choiceNumber)]
end
local normalizedChoice = string.lower(string.Trim(requestedChoice or ""))
for _, mapName in ipairs(nextMapChoices) do
if normalizedChoice == mapName then return mapName end
end
end
local function CastNextMapVote(ply, requestedChoice)
if nextMapVoteFinished then
ply:ChatPrint("[CO-OP] The next map has already been selected: " .. GetConVar("nextlevel"):GetString())
return
end
local mapName = ResolveNextMapChoice(requestedChoice)
if not mapName then
ply:ChatPrint("[CO-OP] That is not one of the current choices.")
ShowNextMapChoices(ply)
return
end
nextMapVotes[PlayerVoteID(ply)] = mapName
local votesForMap = 0
for _, connectedPlayer in ipairs(player.GetHumans()) do
if nextMapVotes[PlayerVoteID(connectedPlayer)] == mapName then
votesForMap = votesForMap + 1
end
end
local required = RequiredVotes(nextMapVoteRatio)
PrintMessage(HUD_PRINTTALK, string.format(
"%s voted for %s as the next map (%d/%d).",
ply:Nick(), mapName, votesForMap, required
))
if votesForMap < required then return end
nextMapVoteFinished = true
RunConsoleCommand("nextlevel", mapName)
PrintMessage(HUD_PRINTTALK, "Next map selected: " .. mapName)
end
local function CountCurrentSkipVotes()
local connectedPlayers = {}
for _, ply in ipairs(player.GetHumans()) do
connectedPlayers[PlayerVoteID(ply)] = true
end
local count = 0
for voterID in pairs(skipVotes) do
if connectedPlayers[voterID] then
count = count + 1
else
skipVotes[voterID] = nil
end
end
return count
end
local function CheckSkipVote()
if changeQueued then return end
local voteCount = CountCurrentSkipVotes()
if voteCount >= RequiredVotes(skipVoteRatio) then
QueueCoopMapChange("player skip vote")
end
end
local function CastSkipVote(ply)
if changeQueued then
ply:ChatPrint("[CO-OP] A map change is already on its way.")
return
end
local voterID = PlayerVoteID(ply)
if skipVotes[voterID] then
ply:ChatPrint("[CO-OP] You have already voted to skip this map.")
return
end
skipVotes[voterID] = true
local voteCount = CountCurrentSkipVotes()
local required = RequiredVotes(skipVoteRatio)
PrintMessage(HUD_PRINTTALK, string.format(
"%s voted to skip this map (%d/%d). Type !skip to vote.",
ply:Nick(), voteCount, required
))
CheckSkipVote()
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 = reason == "player skip vote"
and math.max(skipChangeDelay:GetFloat(), 0)
or math.max(changeDelay:GetFloat(), 0)
PrintMessage(HUD_PRINTTALK, "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("PlayerSay", "CustomCoopMapVoting", function(ply, text)
local command = string.lower(string.Trim(text or ""))
if command == "!skip" or command == "/skip" or command == "!skipmap" then
CastSkipVote(ply)
return ""
end
if command == "!nextmap" or command == "/nextmap"
or command == "!votenext" or command == "/votenext" then
ShowNextMapChoices(ply)
return ""
end
local requestedChoice = string.match(command, "^[!/]votenext%s+(.+)$")
if requestedChoice then
CastNextMapVote(ply, requestedChoice)
return ""
end
end)
hook.Add("PlayerInitialSpawn", "CustomCoopExplainMapVoting", function(ply)
timer.Simple(6, function()
if not IsValid(ply) then return end
ply:ChatPrint("[CO-OP] Type !nextmap to vote for what comes next, or !skip if the current map is stuck.")
end)
end)
hook.Add("PlayerDisconnected", "CustomCoopRecountMapSkipVote", function()
timer.Simple(0, CheckSkipVote)
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()
BuildNextMapChoices()
local limit = maxMapTime:GetFloat()
if limit <= 0 then return end
timer.Create("CustomCoopMapTimeLimit", limit, 1, function()
QueueCoopMapChange("time limit")
end)
end)