diff --git a/gamemode/levels.lua b/gamemode/levels.lua index f47bdd5..876b0f9 100644 --- a/gamemode/levels.lua +++ b/gamemode/levels.lua @@ -403,7 +403,7 @@ COOP_LEVELS = { "ad_coop_mara_final" } -local function GetInstalledCoopLevels() +function GetInstalledCoopLevels() local installedMaps = {} local availableLevels = {} diff --git a/gamemode/maprotation.lua b/gamemode/maprotation.lua index 943c572..2ff7d70 100644 --- a/gamemode/maprotation.lua +++ b/gamemode/maprotation.lua @@ -12,19 +12,45 @@ local maxMapTime = CreateConVar( "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 - - 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 + if not installedMaps then RefreshInstalledMaps() end + return installedMaps[string.lower(string.StripExtension(mapName))] == true end local function SetFreshNextLevel() @@ -35,6 +61,157 @@ local function SetFreshNextLevel() 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 . 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 @@ -49,8 +226,10 @@ function QueueCoopMapChange(reason, requestedMap) end changeQueued = true - local delay = math.max(changeDelay:GetFloat(), 0) - PrintMessage(HUD_PRINTTALK, "Map complete! Loading " .. destination .. " in " .. math.ceil(delay) .. " seconds.") + 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() @@ -58,6 +237,38 @@ function QueueCoopMapChange(reason, requestedMap) 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 @@ -98,6 +309,7 @@ end) hook.Add("InitPostEntity", "CustomCoopStartMapTimeLimit", function() SetFreshNextLevel() + BuildNextMapChoices() local limit = maxMapTime:GetFloat() if limit <= 0 then return end