321 lines
9.9 KiB
Lua
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)
|