-- Leaderboards written by Not -- Reusable local FILENAME = "leaderboard.txt" local lb = {} local timeFinished = 0 local disable = false local prevLap = 0 local splits = {} local PATCH = nil local help = true local UNCLAIMED = "Unclaimed Record" -- Retry / changelevel map local nextMap = nil local Flags = 0 -- SPB flags with the least significance first local F_SPBATK = 0x1 local F_SPBJUS = 0x2 local F_SPBBIG = 0x4 local F_SPBEXP = 0x8 local clearcheats = false local START_TIME = 6 * TICRATE + (3 * TICRATE / 4) local AFK_TIMEOUT = TICRATE * 5 local AFK_BALANCE = TICRATE * 60 local PREVENT_JOIN_TIME = START_TIME + TICRATE * 5 local GUI_OFF = 0x0 local GUI_SPLITS = 0x1 local GUI_ON = 0x2 -- patch caching function local cachePatches local cv_gui = CV_RegisterVar({ name = "lb_gui", defaultvalue = GUI_ON, flags = 0, PossibleValue = {Off = GUI_OFF, Splits = GUI_SPLITS, On = GUI_ON} }) local cv_afk = CV_RegisterVar({ name = "lb_afk", defaultvalue = 1, flags = CV_NETVAR, PossibleValue = CV_OnOff }) local cv_enable = CV_RegisterVar({ name = "lb_enable", defaultvalue = 1, flags = CV_NETVAR | CV_CALL, PossibleValue = CV_OnOff, func = function(v) disable = $ or not v.value end }) local cv_saves = CV_RegisterVar({ name = "lb_save_count", defaultvalue = 20, flags = CV_NETVAR, PossibleValue = CV_Natural }) local cv_interrupt = CV_RegisterVar({ name = "lb_interrupt", defaultvalue = 0, flags = CV_NETVAR | CV_CALL, PossibleValue = CV_OnOff, func = function(v) if v.value then COM_BufInsertText(server, "allowteamchange yes") end end }) local function lbID(map, flags) local id = tostring(map) if flags & F_SPBATK then id = id + "S" end return id end local function score_t(map, name, skin, color, time, splits, flags) return { ["map"] = map, ["name"] = name, ["skin"] = skin, ["color"] = color, ["time"] = time, ["splits"] = splits, ["flags"] = flags } end -- Read the leaderboard local f = io.open(FILENAME, "r") if f then for l in f:lines() do -- Leaderboard is stored in the following tab separated format -- name, skin, color, time, splits, flags local t = {} for word in (l+"\t"):gmatch("(.-)\t") do table.insert(t, word) end local flags = 0 if t[7] != nil then flags = tonumber(t[7]) end local lbt = lb[lbID(t[1], flags)] if lbt == nil then lbt = {} end local spl = {} if t[6] != nil then for str in t[6]:gmatch("([^ ]+)") do table.insert(spl, tonumber(str)) end end table.insert( lbt, score_t( t[1], t[2], t[3], t[4], tonumber(t[5]), spl, flags ) ) lb[lbID(t[1], flags)] = lbt end f:close() else print("Failed to open file: ", FILENAME) end local function allowJoin(v) if not cv_interrupt.value then local y if v then y = "yes" hud.enable("freeplay") else y = "no" hud.disable("freeplay") end COM_BufInsertText(server, "allowteamchange " + y) end end local function ingame() local n = 0 for p in players.iterate do if p.valid and not p.spectator then n = $ + 1 end end return n end local function initLeaderboard(player) if disable and leveltime < START_TIME then disable = ingame() > 1 else disable = disable or ingame() > 1 end disable = $ or not cv_enable.value player.afkTime = leveltime end addHook("PlayerSpawn", initLeaderboard) local function doyoudare(player) if ingame() > 1 or player.spectator then CONS_Printf(player, "How dare you") return false end return true end local function retry(player, ...) if doyoudare(player) then -- Prevents bind crash if leveltime < 20 then return end nextMap = G_BuildMapName(gamemap) end end COM_AddCommand("retry", retry) local function exitlevel(player, ...) if doyoudare(player) then G_ExitLevel() end end COM_AddCommand("exit", exitlevel) local function findMap(player, ...) local search = ... if search == nil then return end for i = 1, #mapheaderinfo do local map = mapheaderinfo[i] if map == nil then continue end if map.lvlttl:lower():find(search:lower()) then CONS_Printf( player, string.format( "%s - %s", G_BuildMapName(i), map.lvlttl ) ) end end end COM_AddCommand("findmap", findMap) local function mapNotExists(player, map) CONS_Printf(player, string.format("Map doesn't exist: %s", map:upper())) end local ALPH = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" local function changelevel(player, ...) if not doyoudare(player) then return end if leveltime < 20 then return end local map = ... if map == nil then CONS_Printf(player, "Usage: changelevel MAPXX") return end local p, q = map:upper():match("MAP(%w)(%w)$", 1) if not (p and q) then CONS_Printf(player, string.format("Invalid map name: %s", map)) return end local mapnum = 0 if tonumber(p) != nil then -- Non extended map numbers if tonumber(q) == nil then mapNotExists(player, map) return end mapnum = tonumber(p) * 10 + tonumber(q) else --Extended map numbers p = ALPH:find(p) - 1 local qn = tonumber(q) if qn == nil then qn = ALPH:find(q) + 9 end mapnum = 36 * p + qn + 100 end if mapheaderinfo[mapnum] == nil then mapNotExists(player, map) return end nextMap = G_BuildMapName(mapnum) end COM_AddCommand("changelevel", changelevel) local function clearcheats(player) if not player.spectator then clearcheats = true CONS_Printf(player, "SPB Attack cheats will be cleared on next round") end end COM_AddCommand("spba_clearcheats", clearcheats) --DEBUGGING --local function printTable(tb) -- for k, v in pairs(tb) do -- for i = 1, #v do -- print("TABLE: " + k, tb[k]) -- if v[i] != nil then -- print( -- v[i]["name"], -- v[i]["skin"], -- v[i]["color"], -- v[i]["time"], -- table.concat(v[i]["splits"]), -- v[i]["flags"], -- "," -- ) -- -- end -- end -- end --end addHook("MapLoad", function() timeFinished = 0 splits = {} prevLap = 0 allowJoin(true) --printTable(lb) end ) local function ticsToTime(tics) if tics == 0 then return "-:--:--" end return string.format( "%d:%02d:%02d", G_TicsToMinutes(tics), G_TicsToSeconds(tics), G_TicsToCentiseconds(tics) ) end -- Item patches have the amazing property of being displaced 12x 13y pixels local iXoffset = 13 * FRACUNIT local iYoffset = 12 * FRACUNIT local function drawitem(v, x, y, scale, itempatch, vflags) v.drawScaled( x * FRACUNIT - FixedMul(iXoffset, scale), y * FRACUNIT - FixedMul(iYoffset, scale), scale, itempatch, vflags ) end local cursors = { [1] = ". ", [2] = " ." } local function marquee(text, maxwidth) if #text <= maxwidth then return text end local shift = 16 -- Creates an index range ranging from -shift to #text + shift local pos = ((leveltime / 16) % (#text - maxwidth + shift * 2)) + 1 - shift local cursor = "" if pos < #text - maxwidth + 1 then cursor = cursors[((leveltime / 11) % #cursors) + 1] end -- The pos is the index going from -shift to #text + shift -- It's clamped within the text boundaries ie. -- 0 < pos < #text - maxwidth pos = min(max(pos, 1), #text - maxwidth + 1) return text:sub(pos, pos + maxwidth - 1) + cursor end -- Bats on ... local bodium = {V_YELLOWMAP, V_GRAYMAP, V_BROWNMAP, 0} local splitColor = {[true]=V_SKYMAP, [false]=V_REDMAP} local splitSymbol = {[true]="-", [false]="+"} local showSplit = 0 local VFLAGS = V_SNAPTOLEFT local FACERANK_DIM = 16 local function drawScore(v, player, i, gui, faceRank, color, name, time, sSplits, flags) --draw Patch/chili -- | OFFSET | + | PADDING | * |INDEX| local h = ((200 / 4) + 4) + (FACERANK_DIM + 4) * (i - 1) v.draw(4, h, faceRank, V_HUDTRANS | VFLAGS, v.getColormap("sonic", color)) if player.name == name then v.draw(4, h, PATCH["CHILI"][(leveltime / 4) % 8], V_HUDTRANS | VFLAGS) end --draw icons --draw name if gui == GUI_ON or (gui == GUI_SPLITS and showSplit) then -- Shorten long names local stralign = "left" local MAXWIDTH = 70 local px = 6 local py = 0 if v.stringWidth(name) > MAXWIDTH then stralign = "thin" py = -1 if v.stringWidth(name, 0, "thin") > MAXWIDTH then stralign = "small" py = 2 if v.stringWidth(name, 0, "small") > MAXWIDTH then name = marquee(name, 15) end end end -- SPB if flags & F_SPBATK then local scale = FRACUNIT / 4 drawitem( v, 4 - 2, h - 2, scale, PATCH["SPB"], V_HUDTRANS | VFLAGS ) if flags & F_SPBEXP then drawitem( v, FACERANK_DIM, h - 2, scale, PATCH["INV"][(leveltime / 4) % 6], V_HUDTRANS | VFLAGS ) end if flags & F_SPBBIG then drawitem( v, 4 - 2, h + FACERANK_DIM - 4, scale, PATCH["BIG"], V_HUDTRANS | VFLAGS ) end if flags & F_SPBJUS then drawitem( v, FACERANK_DIM, h + FACERANK_DIM - 4, scale, PATCH["HYUD"], V_HUDTRANS | VFLAGS ) end end v.drawString( px + FACERANK_DIM, h + py, name, V_HUDTRANSHALF | V_ALLOWLOWERCASE | VFLAGS, stralign ) -- Draw splits if showSplit and sSplits and sSplits[prevLap] != nil then local split = splits[prevLap] - sSplits[prevLap] v.drawString( px + FACERANK_DIM, h + 8, splitSymbol[split < 0] + ticsToTime(abs(split)), V_HUDTRANSHALF | splitColor[split < 0] | VFLAGS ) else v.drawString( px + FACERANK_DIM, h + 8, ticsToTime(time), V_HUDTRANSHALF | bodium[min(i, 4)] | VFLAGS ) end end end local function drawScoreboard(v, player) if disable then return end if player != displayplayers[0] then return end cachePatches(v) local m = lb[lbID(gamemap, Flags)] --if m == nil then -- return --end local gui = cv_gui.value if leveltime < START_TIME or player.exiting or player.lives == 0 then gui = GUI_ON end if gui then -- Draw placeholder score if m == nil then drawScore(v, player, 1, gui, PATCH["NORANK"], nil, UNCLAIMED, 0, nil, 0) else for i, score in ipairs(m) do if i > 5 then break end local name = score["name"] local skin = skins[score["skin"]] if skin == nil then skin = skins["sonic"] end local faceRank = PATCH["FACERANK"][skin.name] drawScore(v, player, i, gui, faceRank, score["color"], score["name"], score["time"], score["splits"], score["flags"]) end end end end hud.add(drawScoreboard, "game") function cachePatches(v) if PATCH == nil then PATCH = {} PATCH["CHILI"] = {} for i = 1, 8 do PATCH["CHILI"][i-1] = v.cachePatch("K_CHILI" + i) end PATCH["NORANK"] = v.cachePatch("M_NORANK") PATCH["FACERANK"] = {} for skin in skins.iterate do PATCH["FACERANK"][skin.name] = v.cachePatch(skin.facerank) end PATCH["SPB"] = v.cachePatch("K_ISSPB") PATCH["INV"] = {} for i = 1, 6 do PATCH["INV"][i - 1] = v.cachePatch("K_ISINV" + i) end PATCH["BIG"] = v.cachePatch("K_ISGROW") PATCH["HYUD"] = v.cachePatch("K_ISHYUD") end end -- True if a is better than b local function lbComp(a, b) -- Calculates the difficulty, harder has higher priority -- if s is positive then a is harder -- if s is negative then b is harder -- if s is 0 then compare time local s = (a["flags"] & (F_SPBEXP | F_SPBBIG)) - (b["flags"] & (F_SPBEXP | F_SPBBIG)) return s > 0 or not(s < 0 or a["time"] >= b["time"]) end local function saveTime(player) local m = lb[lbID(gamemap, Flags)] if m == nil then m = {} end local newscore = score_t( gamemap, player.name, player.mo.skin, player.skincolor, timeFinished, splits, Flags ) -- Check if you beat your previous best for i = 1, #m do if m[i]["name"] == player.name then if lbComp(newscore, m[i]) then table.remove(m, i) S_StartSound(nil, 130) break else -- You suck lol S_StartSound(nil, 201) return end end end print("Saving score") table.insert( m, newscore ) table.sort(m, lbComp) while #m > cv_saves.value do table.remove(m) end lb[lbID(gamemap, Flags)] = m local f = assert(io.open(FILENAME, "w")) if f == nil then print("Failed to open file for writing: " + FILENAME) return end for k, v in pairs(lb) do for i = 1, #v do local s = v[i] f:write( s["map"], "\t", s["name"], "\t", s["skin"], "\t", s["color"], "\t", s["time"], "\t", table.concat(s["splits"], " "), "\t", s["flags"], "\n" ) end end f:close() end -- DEBUGGING --local function saveLeaderboard(player, ...) -- timeFinished = tonumber(... or player.realtime) -- splits = {1000, 2000, 3000} -- saveTime(player) --end --COM_AddCommand("save", saveLeaderboard) local function regLap(player) if player.laps > prevLap and timeFinished == 0 then prevLap = player.laps table.insert(splits, player.realtime) showSplit = 5 * TICRATE end end local function getGamer() for p in players.iterate do if p.valid and not p.spectator then return p end end end local function think() if nextMap then COM_BufInsertText(server, "map " + nextMap) nextMap = nil end if disable then if cv_afk.value and ingame() > 1 then for p in players.iterate do if p.valid and not p.spectator and not p.exiting and p.lives > 0 then if p.cmd.buttons then p.afkTime = leveltime end if p.afkTime + AFK_BALANCE < leveltime then p.spectator = true chatprint("\x89" + p.name + " was moved to the other team for game balance", true) end end end else for p in players.iterate do if p.valid and not p.spectator then p.afkTime = leveltime end end end help = true return end if showSplit > 0 then showSplit = showSplit - 1 end local p = getGamer() if leveltime < START_TIME then -- Help message if leveltime == START_TIME - TICRATE * 3 then if ingame() == 1 then if help then help = false chatprint("\x89Leaderboard Commands:\nretry exit findmap changelevel spba_clearcheats lb_gui", true) end else help = true end end -- Autospec if leveltime == 1 then if p then for s in players.iterate do if s.valid and s.spectator then COM_BufInsertText(s, string.format("view \"%d\"", #p)) end end end end -- Gamemode flags Flags = $ & !(F_SPBATK | F_SPBEXP | F_SPBBIG | F_SPBJUS) if leveltime > START_TIME - (3 * TICRATE) / 2 and server.SPBArunning then Flags = $ | F_SPBATK if server.SPBAexpert then Flags = $ | F_SPBEXP end if clearcheats then clearcheats = false for p in players.iterate do p.SPBAKARTBIG = false p.SPBAjustice = false p.SPBAshutup = false end end for p in players.iterate do if not p.spectator then if p.SPBAKARTBIG then Flags = $ | F_SPBBIG end if p.SPBAjustice then Flags = $ | F_SPBJUS end end end end if not (Flags & F_SPBATK) then hud.enable("freeplay") end end local cv_teamchange = CV_FindVar("allowteamchange") if p then if p.cmd.buttons then p.afkTime = leveltime end if leveltime > PREVENT_JOIN_TIME and p.afkTime + AFK_TIMEOUT > leveltime then if cv_teamchange.value then allowJoin(false) end elseif p.afkTime + AFK_TIMEOUT < leveltime then if not cv_teamchange.value then allowJoin(true) end end if p.laps >= mapheaderinfo[gamemap].numlaps and timeFinished == 0 then timeFinished = p.realtime saveTime(p) end regLap(p) elseif cv_teamchange.value == 0 then allowJoin(true) end end addHook("ThinkFrame", think) local function interThink() if nextMap then COM_BufInsertText(server, "map " + nextMap) nextMap = nil end if not CV_FindVar("allowteamchange").value then allowJoin(true) end end addHook("IntermissionThinker", interThink) local function netvars(net) lb = net($) splits = net($) prevLap = net($) end addHook("NetVars", netvars)