local lib = {} -- Important data needed for the rest of the library local PATH = getSMBXPath().."\\worlds\\_Speedrunner\\" local textplus = require("textplus") local playerManager = require("playerManager") local savestate = require("savestate") local inEpisode = Misc.saveSlot() > 0 local starcoin if not isOverworld then starcoin = require("npcs/AI/starcoin") end local function formatTime(t) if t < 0 then t = -t end realMiliseconds = math.floor(t*15.6) miliseconds = realMiliseconds%1000 realSeconds = math.floor(realMiliseconds/1000) seconds = realSeconds%60 realMinutes = math.floor(realSeconds/60) minutes = realMinutes%60 hours = math.floor(realMinutes/60) if hours < 10 then hours = "0"..tostring(hours) end if minutes < 10 then minutes = "0"..tostring(minutes) end if seconds < 10 then seconds = "0"..tostring(seconds) end if miliseconds < 10 then miliseconds = "00"..tostring(miliseconds) elseif miliseconds < 100 then miliseconds = "0"..tostring(miliseconds) end if hours ~= "00" then return table.concat({hours, minutes, seconds, miliseconds}, ":") else return table.concat({minutes, seconds, miliseconds}, ":") end end local function signSym(n) if n < 0 then return "-" end return "+" end local function tplusColorCode(s, n) if n < 0 then return ""..s.."" elseif n > 0 then return ""..s.."" else return ""..s.."" end end GameData._speeddata = GameData._speeddata or {} local speeddata = GameData._speeddata -- Reset timer when an episode run begins if inEpisode then if speeddata.prevsavefile ~= Misc.saveSlot() or speeddata.prevepisodepath ~= Misc.episodePath() then speeddata.etimer = nil speeddata.log = nil end speeddata.prevepisodepath = Misc.episodePath() speeddata.prevsavefile = Misc.saveSlot() speeddata.etimer = speeddata.etimer or 0 end -- Different speedrun categories local catPowerup = false local catMult = false local catStarcoins = false -- Set up the menu settings local settingsLib = require(PATH.."settings.lua") local settings = settingsLib.data -- Only save the attempt counter from being reset local s_attemptcount = speeddata.attempt local s_prevlevel = speeddata.prevLevel -- reset gamedata to simulate the level being loaded for the first time if settings.disableChecks and not inEpisode then _G.GameData = {_speeddata = {}, _repl = {log = {}, history = {}}, __activatedCheats = {}, _basegame = {bigSwitch = {}}, __checkpoints = { [Level.filename()] = {} } } end speeddata = GameData._speeddata speeddata.attempt = s_attemptcount speeddata.prevLevel = s_prevlevel speeddata.etimer = speeddata.etimer or 0 speeddata.log = speeddata.log or {} speeddata.logger = speeddata.logger or {} speeddata.startState = speeddata.startState or {} speeddata.timer = speeddata.timer or 0 speeddata.attempt = speeddata.attempt or 0 speeddata.starcoin = speeddata.starcoin or {} if not isOverworld then -- Easy workaround to detect if the player collected the starcoins in a run local starcoin_collect = starcoin.collect starcoin.collect = function(coin) local CoinData = starcoin.getTemporaryData() if CoinData[coin.ai2] then speeddata.starcoin[coin.ai2] = true end -- Check if all the starcoins have been collected catStarcoins = (#speeddata.starcoin == starcoin.max()) and (#speeddata.starcoin > 0) starcoin_collect(coin) end -- Need to detect when a new level is loaded through the editor! local prevLevel = speeddata.prevLevel local currLevel = Misc.episodePath()..string.match(Level.filename(), "(.+)%..+$") if prevLevel ~= currLevel then speeddata.logger = {} speeddata.startState = {} speeddata.attempt = 0 speeddata.timer = 0 speeddata.starcoin = {} end speeddata.prevLevel = currLevel end -- Images and lookups local iInputs = Graphics.loadImage(PATH.."inputs.png") local iCategories = Graphics.loadImage(PATH.."categories.png") local iStat = Graphics.loadImage(PATH.."stat.png") local iFavorite = Graphics.loadImage(PATH.."favorite.png") local textfont = textplus.loadFont("textplus/font/6.ini") local timerX = {0, 8, 400, 792} local timerXSplit = {0, 8, 200, 396} local timerPivot = {vector(0, 0), vector(0, 1), vector(0.5, 1), vector(1, 1)} local timerSize = {1, 2, 2.5} local pList = {player, player2} local keyname = {"left", "up", "down", "right", "jump", "altJump", "run", "altRun", "dropItem", "pause"} local inputX = {0, 8, 376, 744} local inputXSplit = {0, 8, 176, 644} local finTypes = { [0] = "Custom Game End", [1] = "Roulette", [2] = "? Orb", [3] = "Keyhole", [4] = "Crystal Orb", [5] = "Game End", [6] = "Star", [7] = "Goal Tape", [8] = "Offscreen Exit", [9] = "Warp Exit", } local forcedTypes = { [FORCEDSTATE_NONE] = "None", [FORCEDSTATE_POWERUP_BIG] = "Powerup Mushroom", [FORCEDSTATE_POWERDOWN_SMALL] = "Powerdown Mushroom", [FORCEDSTATE_PIPE] = "Warp Pipe", [FORCEDSTATE_POWERUP_FIRE] = "Powerup Fire Flower", [FORCEDSTATE_POWERUP_LEAF] = "Powerup Leaf", [FORCEDSTATE_RESPAWN] = "Respawning", [FORCEDSTATE_DOOR] = "Warp Door", [FORCEDSTATE_INVISIBLE] = "Invisible", [FORCEDSTATE_ONTONGUE] = "Yoshi Tongue", [FORCEDSTATE_SWALLOWED] = "Yoshi Swallowed", [FORCEDSTATE_POWERUP_TANOOKI] = "Powerup Tanooki", [FORCEDSTATE_POWERUP_HAMMER] = "Powerup Hammer", [FORCEDSTATE_POWERUP_ICE] = "Powerup Ice", [FORCEDSTATE_POWERDOWN_FIRE] = "Powerdown Fire", [FORCEDSTATE_POWERDOWN_ICE] = "Powerdown Ice", [FORCEDSTATE_MEGASHROOM] = "Mega Mushroom", [FORCEDSTATE_TANOOKI_POOF] = "Tanooki Statue" } -- Important things to keep track of local episodeName = mem(0xB2C624, FIELD_STRING) local forceExitWarp = 0 local isFollowingSectionSplit = false local sectionSplit = 1 local customFinish = {} local hasLevelWon local hasEpisodeWon local levelWinTimeDiff local episodeWinTimeDiff local prevBestRun local savest local hasbegun local notif = {txt = "", timer = -1} -- Things to log while playing the level! local logger = speeddata.logger logger.inputs = logger.inputs or {} logger.sectionsplit = logger.sectionsplit or {} logger.forcedState = logger.forcedState or {} logger.onground = logger.onground or 0 logger.spinjump = logger.spinjump or 0 logger.sliding = logger.sliding or 0 for k, v in ipairs(keyname) do logger.inputs[v] = logger.inputs[v] or 0 end -- Load the library that stores the level's PBs local levelstatLib local levelstat if not isOverworld then levelstatLib = require(PATH.."levelstat.lua") levelstat = levelstatLib.data end -- Load the library that stores the episodes's PBs local worldstatLib local worldstat if inEpisode then worldstatLib = require(PATH.."worldstat.lua") worldstat = worldstatLib.data end -- Input manager for menu local commander = require(PATH.."mycommander.lua") commander.register{{menu = 192, delete = 46, savestate = 116, loadstate = 117, back = {name = "run"}, select = {name = "jump"}, up = {name = "up"}, down = {name = "down"}, left = {name = "left"}, right = {name = "right"}}} -- 192 is the tilde key | 46 is the delete key | 116 is the f5 key | 117 is the f6 key -- Load the menu library and pass important data local menu = require(PATH.."mymenu.lua") menu.data = settings menu.input = commander[1] menu.save = settingsLib.save menu.aScroll = Audio.SfxOpen(PATH.."scroll.ogg") menu.aOpen = Audio.SfxOpen(PATH.."open.ogg") menu.aClose = Audio.SfxOpen(PATH.."close.ogg") menu.aSelect = Audio.SfxOpen(PATH.."select.ogg") -- Inputs for the PBs menu local logtick = function(subdata) local input = menu.input if input.select.time == 1 or (input.select.time > 20 and input.select.time % 15 == 0) then subdata.mode = subdata.mode + 1 if subdata.mode == 4 then subdata.mode = 1 end SFX.play(menu.aScroll) end if input.up.time == 1 or (input.up.time > 20 and input.up.time % 15 == 0) then subdata.suboption = subdata.suboption - 1 SFX.play(menu.aScroll) elseif input.down.time == 1 or (input.down.time > 20 and input.down.time % 15 == 0) then subdata.suboption = subdata.suboption + 1 SFX.play(menu.aScroll) end if subdata.suboption <= 0 then subdata.suboption = math.max(1, #levelstat) elseif subdata.suboption > #levelstat then subdata.suboption = 1 end if menu.input.delete.time == 180 then table.remove(levelstat, subdata.suboption) levelstatLib.save() SFX.play(43) if subdata.suboption > #levelstat then subdata.suboption = 1 end menu.submenu = 0 end end local charNameList = {} local costumelist = {"NONE"} for k, v in pairs(playerManager.getCharacters()) do charNameList[k] = v.name for _, c in ipairs(playerManager.getCostumes(k)) do table.insert(costumelist, c) end end local function getCharName(id) local s = charNameList[id] if s then return s end return "Unknown" end local function getCostumeName(name) if not name then return "None" end return name end local powNameList = {"Small", "Mushroom", "Fire Flower", "Super Leaf", "Tanooki Suit", "Hammer Suit", "Ice Flower"} local function getPowName(id) local s = powNameList[id] if s then return s end return "Unknown" end local mountNameList = {"None", "Goomba Shoe", "Podoboo Shoe", "Lakitu Shoe", "Green Yoshi", "Blue Yoshi", "Yellow Yoshi", "Red Yoshi", "Black Yoshi", "Purple Yoshi", "Pink Yoshi", "Cyan Yoshi", "Clown Car"} local function getMountName(type, color) if type == 0 then return mountNameList[1] elseif type == 1 then return mountNameList[1 + color] elseif type == 2 then return mountNameList[4 + color] elseif type == 3 then return mountNameList[12] end return "Unknown" end local boxNameList = {[9] = "Mushroom", [14] = "Fire Flower", [22] = "Billy Gun", [26] = "Spring", [29] = "Hammer Bro.", [31] = "Key", [32] = "P-Switch", [34] = "Super Left", [35] = "Goomba Shoe", [49] = "Ptooie", [56] = "Koopa Clown Car", [95] = "Green Yoshi", [98] = "Blue Yoshi", [99] = "Yellow Yoshi", [100] = "Red Yoshi", [148] = "Black Yoshi", [149] = "Purple Yoshi", [150] = "Pink Yoshi", [169] = "Tanooki Suit", [170] = "Hammer Suit", [183] = "Fire Flower", [184] = "Mushroom", [185] = "Mushroom", [191] = "Podoboo Shoe", [194] = "Rainbow Shell", [228] = "Cyan Yoshi", [241] = "Pow-Block", [249] = "Mushroom", [250] = "Heart", [264] = "Ice Flower", [277] = "Ice Flower", [278] = "Propeller Block", [279] = "Flamethrower Propeller Block", [293] = "Starman", [325] = "Green Baby Yoshi", [326] = "Red Baby Yoshi", [327] = "Blue Yoshi", [328] = "Yellow Baby Yoshi", [329] = "Baby Black Yoshi", [330] = "Purple Baby Yoshi", [331] = "Pink Baby Yoshi", [332] = "Cyan Yoshi", [334] = "Snake Block", [419] = "Arrow Lift", [425] = "Mega Mushroom", [427] = "Red Spring", [428] = "Sideways Spring", [462] = "Heart", [666] = "Walking Rinka Block"} local function getBoxName(id) if id == 0 then return "None" end local s = boxNameList[id] if s then return id.."("..s..")" end return id end local function textplusPBPrint(text, x, y) textplus.print{text = text, x = x, y = 8 + 26*y, xscale = 2, yscale = 2, plaintext = true, font = textfont, priority = 9.99} end local PBModes = {"BEST", "RUNS", "SAVED"} local function renderLevelStats(logs, id, episodeMode) local offset = 0 if episodeMode then offset = 1 end local selection = logs[id] local category = selection.category local heading = selection.levelName if #logs > 1 then heading = "["..id.."/"..#logs.."] "..heading end local exitname = finTypes[category.type] if category.section ~= -1 then exitname = exitname.." @"..category.section end textplusPBPrint(heading, 8, 2 + offset*5) if not episodeMode then textplusPBPrint(getSMBXVersionString(selection.smbxversion), 8, 3) end textplusPBPrint(selection.date, 8, 4 + offset*4) textplusPBPrint(exitname, 8, 5 + offset*4) for k, v in ipairs({"powerup", "mult", "starcoin"}) do local sourceX, sourceY = 16*(k - 1), 0 if category[v] then sourceY = 16 end Graphics.draw{type = RTYPE_IMAGE, x = 8 + 18*(k - 1), y = 8 + 26*(6 + offset*4), priority = 9.99, image = iCategories, sourceX = sourceX, sourceY = sourceY, sourceWidth = 16, sourceHeight = 16} end textplusPBPrint(formatTime(selection.time).." ["..selection.time.."]", 8, 7 + offset*4) textplusPBPrint("Attempts:"..selection.attempts, 8, 8 + offset*4) for k, pstate in ipairs(selection.startstate) do textplusPBPrint(getCharName(pstate.character).."\n"..getCostumeName(pstate.costume).."\n"..getPowName(pstate.powerup).."\n"..getMountName(pstate.mount, pstate.mountcolor).."\n"..getBoxName(pstate.reserveBox).."\n"..pstate.health, 36 + (k - 1)*300, 9 + offset*4) for i = 1, 6 do Graphics.draw{type = RTYPE_IMAGE, x = 16 + (k - 1)*300, y = 8 + 26*(9 + offset*4) + 18*(i - 1), priority = 9.99, image = iStat, sourceX = 16*(i - 1), sourceY = 0, sourceWidth = 16, sourceHeight = 16} end end end -- Render for PBs menu local logdraw = function(subdata) local starSX = 0 if not actice then starSX = 32 end Graphics.draw{type = RTYPE_IMAGE, x = 8, y = 8, priority = 9.99, image = iFavorite, sourceX = starSX, sourceWidth = 32} for k, v in ipairs(PBModes) do if k == subdata.mode then textplusPBPrint(">"..v.."<", 108 + (k - 1)*200, 0) else textplusPBPrint(" "..v.." ", 108 + (k - 1)*200, 0) end end if #levelstat == 0 then textplusPBPrint("No Records Found!", 8, 2) return end renderLevelStats(levelstat, subdata.suboption) end local function epitick(subdata) local input = menu.input local runs = worldstat.runs if input.select.time == 1 or (input.select.time > 20 and input.select.time % 15 == 0) then subdata.mode = subdata.mode + 1 if subdata.mode == 4 then subdata.mode = 1 end SFX.play(menu.aScroll) end if input.left.time == 1 or (input.left.time > 20 and input.left.time % 15 == 0) then subdata.suboptionx = subdata.suboptionx - 1 SFX.play(menu.aScroll) elseif input.right.time == 1 or (input.right.time > 20 and input.right.time % 15 == 0) then subdata.suboptionx = subdata.suboptionx + 1 SFX.play(menu.aScroll) end if subdata.suboptionx <= 0 then subdata.suboptionx = math.max(1, #runs + 1) elseif subdata.suboptionx > #runs + 1 then subdata.suboptionx = 1 end if input.up.time == 1 or (input.up.time > 20 and input.up.time % 15 == 0) then subdata.suboptiony = subdata.suboptiony - 1 SFX.play(menu.aScroll) elseif input.down.time == 1 or (input.down.time > 20 and input.down.time % 15 == 0) then subdata.suboptiony = subdata.suboptiony + 1 SFX.play(menu.aScroll) end local run if subdata.suboptionx == 1 then run = worldstat.best else run = worldstat.runs[subdata.suboptionx - 1] end if not run then return end if subdata.suboptiony <= 0 then subdata.suboptiony = math.max(1, #run.log) elseif subdata.suboptiony > #run.log then subdata.suboptiony = 1 end end local function epidraw(subdata) local starSX = 0 if not actice then starSX = 32 end Graphics.draw{type = RTYPE_IMAGE, x = 8, y = 8, priority = 9.99, image = iFavorite, sourceX = starSX, sourceWidth = 32} for k, v in ipairs(PBModes) do if k == subdata.mode then textplusPBPrint(">"..v.."<", 108 + (k - 1)*200, 0) else textplusPBPrint(" "..v.." ", 108 + (k - 1)*200, 0) end end local run if subdata.suboptionx == 1 then run = worldstat.best if not run then textplusPBPrint("No Records Found!", 8, 2) return end else run = worldstat.runs[subdata.suboptionx - 1] end local selection = run.log[subdata.suboptiony] local heading = run.episodeName if #worldstat.runs > 1 then heading = "("..subdata.suboptionx.."/"..#worldstat.runs..") "..heading end textplusPBPrint(heading, 8, 2) textplusPBPrint(getSMBXVersionString(run.smbxversion), 8 , 3) textplusPBPrint(run.date, 8, 4) textplusPBPrint(formatTime(run.time).." ["..run.time.."]", 8, 5) renderLevelStats(run.log, subdata.suboptiony, true) end local function sectionsplittable(max) local t = {"HIDE"} for i = 1, max do table.insert(t, tostring(i)) end return t end local function resetGame() menu.toggle() GameData._speeddata = nil Misc.exitGame() end -- Register the menu menu.register{name = "Check Episode PB", type = "submenu", subdata = {suboptionx = 1, suboptiony = 1, mode = 1,}, input = epitick, render = epidraw, levelBanned = true} if not isOverworld then menu.register{name = "Check Level PB", type = "submenu", subdata = {suboption = 1, mode = 1}, input = logtick, render = logdraw} end menu.register{name = "Timer Mode", type = "list", var = "timerMode", list = {"Clock", "Frame", "Clock + Frame"}} menu.register{name = "Timer Size", type = "list", var = "timerSize", list = {"SMALL", "MEDIUM", "LARGE"}} menu.register{name = "Position Timer", type = "list", var = "timerPosition", list = {"HIDE", "LEFT", "CENTER", "RIGHT"}} menu.register{name = "Position Inputs", type = "list", var = "inputPosition", list = {"HIDE", "LEFT", "CENTER", "RIGHT"}} menu.register{name = "Position Attempts", type = "list", var = "attemptsPosition", list = {"HIDE", "LEFT", "CENTER", "RIGHT"}} if not isOverworld then menu.register{name = "Show Section Split", type = "list", var = "sectionsplit", list = sectionsplittable(#levelstat)} end menu.register{name = "Transparent", type = "toggle", var = "transperent"} menu.register{name = "Enable Popout", type = "toggle", var = "popout", episodeBanned = true} menu.register{name = "Print Log", type = "toggle", var = "printlog"} menu.register{name = "Disable Checkpoints", type = "toggle", var = "disableChecks", episodeBanned = true} menu.register{name = "Enable Savestate HotKeys", type = "toggle", var = "enablesavestate", episodeBanned = true} menu.register{name = "Reset Episode", type = "func", func = resetGame, levelBanned = true} menu.register{name = "Enable Extra Advantage Start Features", type = "toggle", var = "enableas", episodeBanned = true} menu.register{name = "[AS] P1 Costume", type = "list", var = "asCostume1", list = costumelist, episodeBanned = true} menu.register{name = "[AS] P2 Costume", type = "list", var = "asCostume2", list = costumelist, episodeBanned = true} menu.register{name = "[AS] P1 Reserve Box", type = "numpad", var = "asBox1", min = 0, max = NPC_MAX_ID, episodeBanned = true} menu.register{name = "[AS] P2 Reserve Box", type = "numpad", var = "asBox2", min = 0, max = NPC_MAX_ID, episodeBanned = true} menu.register{name = "[AS] P1 Health", type = "list", var = "asHealth1", list = {"1", "2", "3"}, episodeBanned = true} menu.register{name = "[AS] P2 Health", type = "list", var = "asHealth2", list = {"1", "2", "3"}, episodeBanned = true} -- Check if the episode or level has a custom finish local file = io.open(Misc.episodePath().."speedrun_custom_ending.lua", "r") -- r read mode if file and not isOverworld then file:close() customFinish = require("speedrun_custom_ending") if type(customFinish) == "string" then customFinish = {[Level.filename()] = customFinish} end end -- Checks if a player has an "advantage" (not small default) local hasHealth = {[CHARACTER_PEACH] = true, [CHARACTER_TOAD] = true, [CHARACTER_LINK] = true, [CHARACTER_KLONOA] = true, [CHARACTER_ROSALINA] = true} local function checkadvantage(p) return playerManager.getCostume(p.character) or p.powerup ~= 1 or p.mount ~= 0 or (hasHealth[p.character] and p:mem(0x16, FIELD_WORD) ~= 1) or (not hasHealth[p.character] and p.reservePowerup ~= 0) end -- Checks if the table share the same values local function sameTable(t1, t2) for k, v in pairs(t1) do if v ~= t2[k] then return false end end return true end local function parseElem(v, form, size) local sizeDiff = size - #tostring(v) if form == 'type' then v = string.rep(" ", sizeDiff)..v..":" elseif form == 'value' then v = string.rep(" ", sizeDiff)..tostring(v) elseif form == 'percentage' then v = "%"..string.rep(" ", sizeDiff)..tostring(v) elseif form == 'timeFrame' then v = "["..string.rep(" ", sizeDiff)..tostring(v).."]" elseif form == 'timeClock' then v = string.rep(" ", sizeDiff)..v end return v end -- Helper to make lists look nice local function generateList(header, anatomy, list) local sizes = {} for k, v in ipairs(list) do for p, q in ipairs(v) do sizes[p] = math.max(sizes[p] or 0, #tostring(q)) end end local s = header..":\n" for k, v in ipairs(list) do s = s.." * " for p, q in ipairs(v) do s = s..parseElem(q, anatomy[p], sizes[p]).." " end s = s.."\n" end return s end local function displayPopout(finType, exitSection) local title = "SPEEDRUNNER POPOUT - LEVEL COMPLETE" if levelWinTimeDiff then if levelWinTimeDiff < 0 then title = "SPEEDRUNNER POPOUT - NEW BEST" elseif levelWinTimeDiff > 0 then title = "SPEEDRUNNER POPOUT - BETTER LUCK NEXT TIME" else title = "SPEEDRUNNER POPOUT - TIED UP THE LEVEL" end end local txt = "=== "..Level.name().." ===\n" txt = txt.."SMBX Version: "..getSMBXVersionString(SMBX_VERSION).."\n" txt = txt.."Timestamp: "..os.date().."\n" txt = txt.."Exit Type: "..finTypes[finType].." @"..exitSection.."\n" txt = txt.."\n" txt = txt.."Time: "..formatTime(speeddata.timer).." ["..speeddata.timer.."]\n" if levelWinTimeDiff then local sign = signSym(levelWinTimeDiff) txt = txt.."Best: "..formatTime(speeddata.timer - levelWinTimeDiff).." ["..(speeddata.timer - levelWinTimeDiff).."]\n" txt = txt.."Time Difference of: "..sign..formatTime(levelWinTimeDiff).." ["..sign..math.abs(levelWinTimeDiff).."]\n" end txt = txt.."\n" txt = txt.."\n" txt = txt..generateList("CATEGORIES", {'type', 'value'}, {{"Advatage Start", catPowerup}, {"Multiplayer", catMult}, {"Starcoins", catStarcoins}}) txt = txt.."\n" txt = txt.."\n" for k, pstate in ipairs(speeddata.startState) do txt = txt..generateList("PLAYER "..k, {'type', 'value'}, {{"Character", getCharName(pstate.character)}, {"Costume", getCostumeName(pstate.costume)}, {"Powerup", getPowName(pstate.powerup)}, {"Mount", getMountName(pstate.mount, pstate.mountcolor)}, {"Health", pstate.health}}) txt = txt.."\n" txt = txt.."\n" end local sectionData = {} for k, v in pairs(logger.sectionsplit) do table.insert(sectionData, {v.id, formatTime(v.time), v.time, math.floor(v.time/speeddata.timer*10000)/100}) end txt = txt..generateList("SECTION", {'type', 'timeClock', 'timeFrame', 'percentage'}, sectionData) txt = txt.."\n" txt = txt.."\n" local inputData = {} for k, v in pairs(logger.inputs) do table.insert(inputData, {k, formatTime(v), v, math.floor(v/speeddata.timer*10000)/100}) end txt = txt..generateList("INPUTS", {'type', 'timeClock', 'timeFrame', 'percentage'}, inputData) txt = txt.."\n" txt = txt.."\n" local forcedStateData = {} for k, v in pairs(logger.forcedState) do table.insert(forcedStateData, {forcedTypes[k], "["..k.."]:", formatTime(v), v, math.floor(v/speeddata.timer*10000)/100}) end txt = txt..generateList("FORCED STATE", {'type', 'value', 'timeClock', 'timeFrame', 'percentage'}, forcedStateData) txt = txt.."\n" txt = txt.."\n" txt = txt..generateList("MISC", {'type', 'timeClock', 'timeFrame', 'percentage'}, {{"Touching Ground", formatTime(logger.onground), logger.onground, math.floor(logger.onground/speeddata.timer*10000)/100}, {"Sliding", formatTime(logger.sliding), logger.sliding, math.floor(logger.sliding/speeddata.timer*10000)/100}, {"Spin Jumping", formatTime(logger.spinjump), logger.spinjump, math.floor(logger.spinjump/speeddata.timer*10000)/100}}) if settings.printlog then time = os.date("*t") local writefile = io.open(Misc.episodePath().."SPEEDLOG "..time.year.."-"..time.month.."-"..time.day.." - "..time.hour .."-".. time.min .."-".. time.sec.." "..string.match(Level.filename(), "(.+)%..+$")..".txt", "w") if not writefile then return end writefile:write(txt) writefile:close() end if settings.popout and not inEpisode then Misc.showRichDialog(title, txt, true) end end local function timeFinish(finType, finSec) finSec = finSec or -1 -- Create run object local category = {type = finType, section = finSec, powerup = catPowerup, mult = catMult, starcoin = catStarcoins} local newLevelRun = {diff = levelWinTimeDiff, category = category, time = speeddata.timer, sectionsplit = logger.sectionsplit, date = os.date(), attempts = speeddata.attempt, smbxversion = SMBX_VERSION, startstate = speeddata.startState, levelName = Level.name(), episodeName = episodeName} -- Check if there is a run stored of the same category local oldLevelRun, oldLevelRunKey for k, v in ipairs(levelstat) do if sameTable(category, v.category) then oldLevelRunKey, oldLevelRun = k, v break end end -- If a run of the same category exists, then get the diff. time if oldLevelRun then levelWinTimeDiff = newLevelRun.time - oldLevelRun.time newLevelRun.diff = levelWinTimeDiff end hasLevelWon = true if finType == -1 or finType == LEVEL_END_STATE_GAMEEND then hasEpisodeWon = true end -- Show log displayPopout(finType, finSec) -- Save the run if its a new best time! if not oldLevelRun then table.insert(levelstat, newLevelRun) elseif newLevelRun.time < oldLevelRun.time then prevBestRun = oldLevelRun levelstat[oldLevelRunKey] = newLevelRun end levelstatLib.save() if inEpisode then -- When playing in an episode, log the level to the episode data table.insert(speeddata.log, newLevelRun) -- When the episode is beaten if hasEpisodeWon then local newEpisodeRun = {time = speeddata.etimer, date = os.date(), smbxversion = SMBX_VERSION, episodeName = episodeName, log = speeddata.log} local oldEpisodeRun = worldstat.best -- Get the time diff for an episode run if oldEpisodeRun then episodeWinTimeDiff = newEpisodeRun.time - oldEpisodeRun.time end -- Save the run if its a new best time! if not oldEpisodeRun or newEpisodeRun.time < oldEpisodeRun.time then worldstat.best = newEpisodeRun end -- Save the run into the run history table.insert(worldstat.runs, 1, newEpisodeRun) worldstat.runs[51] = nil -- Only save the latest 50 runs worldstatLib.save() end end end function lib.onStart() -- Keep track amount of attempts in the run speeddata.attempt = speeddata.attempt + 1 -- Advantage start features the editor is missing if settings.enableas then -- Costume playerManager.setCostume(player.character, costumelist[settings.asCostume1]) if player2 then playerManager.setCostume(player2.character, costumelist[settings.asCostume2]) end -- Reserve Box player.reservePowerup = settings.asBox1 if player2 then player2.reservePowerup = settings.asBox2 end -- Health player:mem(0x16, FIELD_WORD, settings.asHealth1) if player2 then player2:mem(0x16, FIELD_WORD, settings.asHealth2) end end -- Detect if the player begins with any powerups, monuts, etc catPowerup = checkadvantage(player) -- Detect if the level is being played in multiplayer if player2 then catMult = true if not catPowerup then catPowerup = checkadvantage(player2) end end -- Store initial player data -- character, costume, powerup, mount, reserveBox, health if not speeddata.startState[1] then local startState = speeddata.startState for k, p in ipairs(pList) do startState[k] = { character = p.character, costume = playerManager.getCostume(p.character), powerup = p.powerup, mount = p.mount, mountcolor = p.mountColor, reserveBox = p.reservePowerup, health = p:mem(0x16, FIELD_WORD) } end end -- Display the starting time for a bit as a fix tracking time when playing in an episode local startingTime = speeddata.timer if inEpisode then Routine.run(function() for i = 1, 30 do textplus.print{text = formatTime(startingTime).." ["..startingTime.."]", x = 8, y = 8, priority = 9.99, font = textfont} Routine.skip() end end) end end function lib.onTick() -- Check for inputs if settings.enablesavestate and not inEpisode then if menu.input.savestate.state == KEYS_PRESSED then SFX.play(menu.aSelect) savest = savestate.save() notif = {text = "Savestate Saved", timer = 0} elseif menu.input.loadstate.state == KEYS_PRESSED and savest then SFX.play(menu.aClose) savestate.load(savest) notif = {text = "Savestate Loaded", timer = 0} end end -- Log data for the logger for k, v in pairs(player.keys) do if v then logger.inputs[k] = logger.inputs[k] + 1 end end if player.forcedState ~= 0 then logger.forcedState[player.forcedState] = (logger.forcedState[player.forcedState] or 0) + 1 end if player:isGroundTouching() then logger.onground = logger.onground + 1 end if player:mem(0x3C, FIELD_BOOL) then -- is Sliding logger.sliding = logger.sliding + 1 end if player:mem(0x50, FIELD_BOOL) then -- is Spinjumping logger.spinjump = logger.spinjump + 1 end end function lib.onWorldDraw() speeddata.etimer = speeddata.etimer + 1 lib.onCameraDraw(1) end function lib.onLevelDraw() -- Detection when the level has been finished if Level.winState() ~= 0 and not hasLevelWon then timeFinish(Level.winState(), player.section) end -- Count the level timer if not hasLevelWon then speeddata.timer = speeddata.timer + 1 -- Section logger! if logger.prevSection ~= player.section then table.insert(logger.sectionsplit, {id = player.section, time = 0}) end logger.sectionsplit[#logger.sectionsplit] = logger.sectionsplit[#logger.sectionsplit] or {id = player.section, time = 0} logger.sectionsplit[#logger.sectionsplit].time = logger.sectionsplit[#logger.sectionsplit].time + 1 logger.prevSection = player.section end -- Count the episode timer if inEpisode and not hasEpisodeWon then speeddata.etimer = speeddata.etimer + 1 end -- Fix to force exit by warp be detected for k, p in ipairs(Player.get()) do if p.prevWarp and p.prevWarp > 0 and p:mem(0x122, FIELD_WORD) > 0 then local warp = Warp.get()[p.prevWarp] if warp and (warp.toOtherLevel or warp.levelFilename ~= "") then forceExitWarp = k end end p.prevWarp = p:mem(0x5A, FIELD_WORD) end end local function formatOut(n) if settings.timerMode == 1 then return formatTime(n) elseif settings.timerMode == 2 then return tostring(n) elseif settings.timerMode == 3 then return formatTime(n).." ["..n.."]" end end local function formatFin(obj, diff) local sym = signSym(diff) if diff < 0 then obj.text = ""..obj.text.."" elseif diff > 0 then obj.color = Color.red*obj.color.a else obj.color = Color.gray*obj.color.a end obj.text = obj.text.." "..sym..formatOut(diff).."" end local pos = {vector(0, 8, 9, 8), vector(8, 0, 8, 9), vector(8, 15, 8, 9), vector(15, 8, 9, 8), vector(32, 14, 8, 8), vector(39, 8, 8, 8), vector(25, 7, 8, 8), vector(32, 0, 8, 8), vector(16, 17, 8, 6), vector(24, 17, 8, 6)} local function renderInputs(p, x, y) local opacity = 1 if settings.transperent then opacity = 0.5 end Graphics.draw{image = iInputs, type = RTYPE_IMAGE, sourceWidth = 48, x = x, y = y, priority = 9.9, opacity = opacity, priority = 9.99} for k, v in ipairs(keyname) do if p.rawKeys[v] then local sourceX = pos[k].x local sourceY = pos[k].y Graphics.draw{image = iInputs, type = RTYPE_IMAGE, sourceX = sourceX + 48, sourceY = sourceY, sourceWidth = pos[k].z, sourceHeight = pos[k].w, x = x + sourceX, y = y + sourceY, priority = 9.9, priority = 9.99} end end end -- Draw the timer and inputs and attempts and section split function lib.onCameraDraw(idx) -- These are the objects that will be printed onscreen local opacity = 1 local timerObj = {x = timerX[settings.timerPosition], pivot = timerPivot[settings.timerPosition], priority = 9.99, font = textfont, xscale = timerSize[settings.timerSize], yscale = timerSize[settings.timerSize], color = Color.white, height = timerSize[settings.timerSize]*12} local attemptObj = {text = "#"..speeddata.attempt, x = timerX[settings.attemptsPosition], pivot = timerPivot[settings.attemptsPosition], priority = 9.99, font = textfont, xscale = 2, yscale = 2, color = Color.white, height = 16} local inputObj = {x = inputX[settings.inputPosition], height = 24} -- Change position if there is splitscreen if camera.width == 400 then timerObj.x = timerXSplit[settings.timerPosition] attemptObj.x = timerXSplit[settings.attemptsPosition] inputObj.x = inputXSplit[settings.inputPosition] end -- Print the text depending on the format needed timerObj.text = formatOut(speeddata.timer) -- Make text transperent if settings.transperent then opacity = 0.5 end if inEpisode then timerObj.height = timerObj.height*2 end if camera.width == 800 and camera.height == 600 and player2 then inputObj.height = inputObj.height*2 end -- Change the y position if multiple objects overlap --[[ * attempt * input * time (level) * time (episode) --]] timerObj.y = 592 if settings.timerPosition > 1 and settings.timerPosition == settings.inputPosition then inputObj.y = timerObj.y - timerObj.height else inputObj.y = 592 end if settings.inputPosition > 1 and settings.inputPosition == settings.attemptsPosition then attemptObj.y = inputObj.y - inputObj.height - 4 elseif settings.timerPosition > 1 and settings.timerPosition == settings.attemptsPosition then attemptObj.y = timerObj.y - timerObj.height else attemptObj.y = 592 end if camera.height == 300 then attemptObj.y = attemptObj.y - 300 inputObj.y = inputObj.y - 300 timerObj.y = timerObj.y - 300 end -- Print inputs if settings.inputPosition > 1 then if camera.width == 800 and camera.height == 600 and player2 then renderInputs(player, inputObj.x, inputObj.y - inputObj.height - 4) renderInputs(player2, inputObj.x, inputObj.y - inputObj.height*0.5) else renderInputs(pList[idx], inputObj.x, inputObj.y - inputObj.height) end end -- Print attempts if settings.attemptsPosition > 1 then attemptObj.color = attemptObj.color*opacity textplus.print(attemptObj) end -- Print timer (level and episode) if settings.timerPosition > 1 and timerObj.text then timerObj.color = timerObj.color*opacity if inEpisode then local etimerObj = table.clone(timerObj) timerObj.y = timerObj.y - etimerObj.height*0.5 etimerObj.text = formatOut(speeddata.etimer) if hasEpisodeWon and episodeWinTimeDiff then formatFin(etimerObj, episodeWinTimeDiff, opacity) end textplus.print(etimerObj) end if hasLevelWon and levelWinTimeDiff then formatFin(timerObj, levelWinTimeDiff, opacity) end textplus.print(timerObj) end -- Print the category when the level is finished if hasLevelWon then for k, v in ipairs({catPowerup, catMult, catStarcoins}) do local xs, ys = 16*(k - 1), 0 if v then ys = 16 end Graphics.draw{type = RTYPE_IMAGE, x = 16 + 18*(k - 1) - 4, y = 8 + 4, priority = 9.99, image = iCategories, sourceX = xs, sourceY = ys, sourceWidth = 16, sourceHeight = 16} end end -- Print the notification if notif.timer >= 0 then notif.timer = notif.timer + 1 local off = 0 if notif.timer < 10 then off = 32*math.sin(notif.timer*0.3) elseif notif.timer > 120 then notif.timer = -1 end textplus.print{text = notif.text, x = 760 + off, y = 8, pivot = {1, 0}, priority = 9.99, font = textfont, xscale = 2, yscale = 2, plaintext = true, color = Color.red*opacity} end -- The splitter changes if in a world map if isOverworld then -- Print the episode log if settings.timerPosition > 1 and #speeddata.log > 0 then for i = 1, math.min(#speeddata.log, 10) do local v = speeddata.log[i] local s = formatTime(v.time) local d = formatTime(math.abs(v.diff or 0)) local c = Color.white*opacity if v.diff and v.diff > 0 then d = "+"..d.."" elseif v.diff and v.diff < 0 then d = "-"..d.."" else d = " "..d end textplus.print{text = s.." "..d, x = 792, y = 8 + 10*i, pivot = {1, 0}, priority = 9.99, font = textfont, xscale = 1, yscale = 1, color = c} end end else -- Print the section splitter if settings.sectionsplit > 0 then local selectedRun = levelstat[settings.sectionsplit - 1] if not selectedRun then settings.sectionsplit = 1 return end local selectedCat = selectedRun.category local secList = selectedRun.sectionsplit if prevBestRun then secList = prevBestRun.sectionsplit end -- Print exit name type and section local exitname = finTypes[selectedCat.type] if selectedCat.section ~= -1 then exitname = exitname.." @"..selectedCat.section end textplus.print{text = exitname, x = 800 - 8, y = 8, pivot = {1, 0}, priority = 9.99, font = textfont, color = Color.white*opacity} -- Print category icons for k, v in ipairs({"powerup", "mult", "starcoin"}) do local sourceX, sourceY = 16*(k - 1), 0 if selectedCat[v] then sourceY = 16 end Graphics.draw{type = RTYPE_IMAGE, x = 800 - 6 - (3 - k + 1)*18, y = 8 + 10, priority = 9.99, image = iCategories, sourceX = sourceX, sourceY = sourceY, sourceWidth = 16, sourceHeight = 16, opacity = opacity} end -- Print timers for k, v in ipairs(secList) do local t = formatTime(v.time) local splitColor = Color.white if logger.sectionsplit[k] and logger.sectionsplit[k].id == v.id then local d = logger.sectionsplit[k].time - v.time t = signSym(d)..formatTime(d) if d < 0 then t = ""..t.."" elseif d > 0 then splitColor = Color.red else splitColor = Color.gray end end splitColor = splitColor*opacity textplus.print{text = t, x = 800 - 8, y = 8 + 18+10 + 8 + (k - 1)*10, pivot = {1, 0}, priority = 9.99, font = textfont, color = splitColor} end end end end function lib.onExitLevel(type) -- Fix for exit by warp if forceExitWarp ~= 0 then type = LEVEL_WIN_TYPE_WARP end if type ~= 0 then if not hasLevelWon then -- Detection when the level has been finished (for win types not accounted by Level.winState()) hasLevelWon = true if type == LEVEL_WIN_TYPE_OFFSCREEN then timeFinish(8, player.section) elseif type == LEVEL_WIN_TYPE_WARP then timeFinish(9, player.section) end end end -- This means the level was "beat" if hasLevelWon then speeddata.logger = nil speeddata.timer = nil speeddata.starcoin = nil speeddata.attempt = nil speeddata.startState = nil end -- This means the episide was "beat" if hasEpisodeWon then speeddata.etimer = nil speeddata.log = nil end -- If checkpoints are disabled, force the level to reload as if it was loaded for the first time if settings.disableChecks and not inEpisode then Checkpoint.reset() mem(0x00B250B0, FIELD_STRING, "") -- Clear vanilla checkpoint mem(0x00B2C5A8, FIELD_WORD, 0) -- Clear coins mem(0x00B2C8E4, FIELD_DWORD, 0) -- Clear score SaveData.clear() Misc.saveGame() end end -- For when the episode has a custom end game function lib.onEvent(eventname) if customFinish[Level.filename()] == eventname then timeFinish(-1, player.section) end end Misc.cheatBuffer("SMBXSPEEDRUNNER") function lib.onInputUpdate() if Misc.cheatBuffer() == "" then SFX.play(menu.aScroll) Misc.cheatBuffer("SPEEDRUNNER") notif = {text = "CHEAT DETECTED", timer = 0} end end function lib.onInitAPI() if not isOverworld then registerEvent(lib, "onStart", "onStart") registerEvent(lib, "onEvent", "onEvent") registerEvent(lib, "onInputUpdate", "onInputUpdate") registerEvent(lib, "onTick", "onTick") registerEvent(lib, "onExitLevel", "onExitLevel") registerEvent(lib, "onCameraDraw", "onCameraDraw") end if isOverworld then registerEvent(lib, "onDraw", "onWorldDraw") else registerEvent(lib, "onDraw", "onLevelDraw") end end return lib