local gui = require("gui") local color = require("gui.core.color") local loader = require("utils/loader") local theme = require("gui.core.theme") local fmt = require("utils/fmt") local timer = require("utils/utils") local multi, thread = require("multi"):init() local menu = require("gui.core.menus") local transition = require("gui.core.transitions") local logger = require("utils/logger") local timesup = love.audio.newSource("assets/sounds/timesup.mp3", "static") local double = love.audio.newSource("assets/sounds/double.mp3", "static") local boardUpdater = gui:getProcessor():newProcessor("board-updater") local scoreUpdater = gui:getProcessor():newProcessor("score-updater") local qUpdater = gui:getProcessor():newProcessor("question-updater") local activePlayer local playerList = {} local playerStaticList = {} local scoreboard = {} local handler local handler_conn = boardUpdater:newConnection() local reference local multiply local reset = boardUpdater:newConnection() local default_text = "Pick a Category" local boardLog = log:child("board") local completed_questions = 0 local min_questions = 0 local dd_count = 0 local dd_enabled = false local applied_dd = false local board, question local function cleanUp(frameRef) boardLog:debug("Cleaning up state and returning to title screen") scoreboard:removeAllPlayers() activePlayer = nil handler_conn:Remove() playerList = {} playerStaticList = {} scoreboard = {} handler = nil reference = nil multiply = nil reset:Remove() default_text = "Pick a Category" completed_questions = 0 min_questions = 0 dd_enabled = false applied_dd = false board = nil question = nil frameRef:destroy() end local function pickUniqueIndices(t, count) assert(#t >= count, "Not enough elements to pick " .. count .. " unique items") local indices = {} for i = 1, #t do indices[i] = i end -- partial Fisher-Yates shuffle for i = 1, count do local j = math.random(i, #indices) indices[i], indices[j] = indices[j], indices[i] end return unpack(indices, 1, count) end local function pickFiltered(items, count) -- Filter to only elements whose text contains "$" local filtered = {} for _, item in ipairs(items) do if item.text:find("%$") then filtered[#filtered + 1] = item end end -- Use pickUniqueIndices to select from filtered results local picks = { pickUniqueIndices(filtered, count) } local results = {} for _, idx in ipairs(picks) do results[#results + 1] = filtered[idx] end return unpack(results) end function applyDD() if not applied_dd and completed_questions > min_questions then boardLog:debug("Creating double jeopardy questions: %d",dd_count) local dd = 0 local dd_list = {} local dds = {pickFiltered(board.children, dd_count)} for i,v in pairs(dds) do v.isDouble = true end applied_dd = true end end local function setMultiplier(m) multiply = m end local function useDefaultHandler(callback) handler.visible = true reference = handler_conn(callback) end local templateLog = log:child("template") function LoadTemplate(name, path) boardLog:debug("Loading Template: %s", name) if love.filesystem.getInfo(path .."/templates/" .. name .. ".lua") then data = love.filesystem.read(path .."/templates/" .. name .. ".lua") elseif love.filesystem.getInfo("templates/" .. name .. ".lua") then data = love.filesystem.read("templates/" .. name .. ".lua") else boardLog:error("Error Loading Template: %s", name) gui:newMessageBox({ title = "Error!", message = "Unable to load template: " .. name, buttons = { "Yes", "No" }, onChoice = function(label, idx) if label == "Yes" then doCallback(selected) pickerWindow.visible = false pickerWindow:setTag("visual") end end, }) return end local template, err = loadstring(data) if err ~= nil then error(err) end local timer = function(wait, callback) multi:newAlarm(wait):OnRing(callback) end local env = { -- lua built-ins except io/os code execution pairs=pairs, print=print, tostring=tostring, tonumber=tonumber, type=type, assert=assert, ipairs=ipairs, table=table, math=math, string=string, next=next, pcall=pcall, select=select, xpcall=xpcall, utf8=utf8, clock = require("socket").gettime, ALIGN_CENTER = gui.ALIGN_CENTER, ALIGN_LEFT = gui.ALIGN_LEFT, ALIGN_RIGHT = gui.ALIGN_RIGHT, ALIGN_JUSTIFY = gui.ALIGN_JUSTIFY, -- Global vars color = color, error = multi.error, theme = theme, gui = gui, timer = timer, useDefaultHandler = useDefaultHandler, setMultiplier = setMultiplier, -- love stuff newSource = love.audio.newSource, log = templateLog, } env._G = env local isoTemplate = multi.isolateFunction(template, env) return isoTemplate() end local function manageCosmetics(frame, cosmetics) boardLog:debug("Managing cosmetics") if type(cosmetics.colors) == "table" then for i,v in pairs(cosmetics.colors) do color.reindexColor((i:gsub("-","_")),v) end end if cosmetics["bg-img"] then frame:setImage(cosmetics["bg-img"]) frame:setShader() else -- frame:setImage("assets/images/background.png") frame:setImage(nil) frame:setShader(gui.SHADERS.background,{ colA = color.shaderColA, colB = color.shaderColB, colC = color.shaderColC }) end end function gui:cleanup() for i = #self.children, 1, -1 do self.children[i]:destroy() end self.children = {} completed_questions = completed_questions + 1 end function Menu(frame, path, deb) local frameRef = frame:newImageLabel():fullFrame() frameRef.visibility = 0 boardLog:info("Building board") if path:match(".zip") then local success = love.filesystem.mount(path, "quiz", true) if not success then gui:newMessageBox({ title = "Return to the main menu?", message = "Couldn't load file: " .. path, buttons = { "Return", "Quit" }, onChoice = function(label, idx) if label == "Return" then return menu.getMenu("title"):visible(true) else os.exit() end end, }) return end path = "quiz" end local scoreboard = ScoreBoard(frameRef, 0, 0, 0, 0, .06, .1, .170, .8) local qframe = frameRef:newFrame(0, 0, 0, 0, .24, .1, .70, .8) local dframe = frameRef:newTextLabel("",0, 0, 0, 0, .24, .1, .7, .8) dframe:scaleFont(.075) dframe:centerFont() dframe.align = gui.ALIGN_CENTER dframe.color = color.bg dframe.visible = false dframe:OnReleased(function(self) if self.click then boardUpdater:newThread(function() thread.skip(1) dframe.visible = false end) end end) if deb then boardLog.level = logger.LEVELS.DEBUG templateLog.level = logger.LEVELS.DEBUG boardLog:debug("Debug mode is activated when quiz directory is in the save directory") local refresh = frameRef:newTextButton("Refresh", 0, -30, 140, 30, 0, 1) refresh:centerFont() refresh:scaleFont(.9) refresh.align = gui.ALIGN_CENTER refresh.color = color.green refresh:OnReleased(function() cleanUp(frameRef) menu.getMenu("title"):visible(true) menu.getMenu("board"):visible(frame, path, deb) end) end -- Start the processors now boardLog:debug("Starting processors") boardUpdater.Start() boardLog:debug("Starting board-updater Started") qUpdater.Start() boardLog:debug("Starting question-updater Started") scoreUpdater.Start() boardLog:debug("Starting score-updater Started") local glide = transition.glide() local function displayAnswer(q) dframe.text = q.answer dframe.textColor = color.white dframe.visible = true dframe.click = q["display-mode"] == "click" local sleep = 5 local fade = 1 if type(q["display-mode"]) == "number" then sleep = q["display-mode"] end if type(q["display-fade"]) == "number" and q["display-fade"] > 0 then fade = q["display-fade"] end if not dframe.click then boardUpdater:newThread(function() thread.sleep(sleep) local handle = glide(1,0,fade) handle:OnStep(function(p) dframe.textVisibility = p dframe.visibility = p end) handle:OnStop(function() dframe.visible = false dframe.textVisibility = 1 dframe.visibility = 1 handle.OnStep:Remove() handle.OnStop:Remove() end) end) end end local label = frameRef:newTextLabel(default_text, 0, 10, 0, -20, .29, 0, .6, .1) handler = frameRef:newFrame(0, 10, 0, -20, .29, .9, .6, .1) handler.visible = false local correct = handler:newTextButton("Correct", 5,0,-10,0,0,.1,1/3,.8) correct.color = color.correct local skip = handler:newTextButton("Skip", 8,0,-15,0,1/3,.1,1/3,.8) skip.color = color.skip local wrong = handler:newTextButton("Wrong", 5,0,-10,0,2/3,.1,1/3,.8) wrong.color = color.wrong gui.apply({ setRoundness = {15,15,60}, centerFont = {}, scaleFont = {1/2}, align = gui.ALIGN_CENTER, OnEnter = function(self) self:setShader(gui.SHADERS.glow) end, OnExit = function(self) self:setShader() end, shaderTime = {true}, OnReleased=function(self) if self.text == "Skip" then handler_conn:Fire() reference:Unconnect() handler.visible = false return end local val = self.text == "Correct" handler_conn:Fire(val, multiply) if val then if reference then reference:Unconnect() end handler.visible = false end end, },correct,skip,wrong) label.visibility = .85 label.drawBorder = false label.align = gui.ALIGN_CENTER label.color = color.bg label.textColor = color.status_font label:setRoundness(20,20,60) handler:setRoundness(20,20,60) handler.visibility = .9 handler.color = color.bg label:scaleFont(2/3) label:centerFont() local data = loader:new(path) board, question, dailydouble = qframe:newFrame(), qframe:newFrame(), qframe:newImageLabel("assets/images/double.jpg") index = data.index local cosmetics = index.cosmetics or {} cosmetics.colors = cosmetics.colors or {} for i,v in pairs(color.listColors()) do if not cosmetics.colors[v] then cosmetics.colors[v] = color.rgbToHex(color[v]) end end frameRef:shaderTime() manageCosmetics(frameRef, cosmetics) board:fullFrame() question:fullFrame() dailydouble:fullFrame() dailydouble.visible = false question.visible = false question.color = color.question_bg local tiers = index.settings.tiers or 5 local start = index.settings.start or 100 local inc = index.settings.increment or 100 if index.settings.dailyDouble and index.settings.dailyDouble.enabled then local dd = index.settings.dailyDouble dd_enabled = dd.enabled min_questions = dd.minQuestions dd_count = dd.count end local autoreveal = false if index.dev and index.dev.enabled then if index.dev["auto-reveal"] then autoreveal = true end if type(index.dev.players) == "table" then for i,v in ipairs(index.dev.players) do if type(v) == "string" then scoreboard:AddPlayer(v, 0) elseif type(v) == "table" then scoreboard:AddPlayer(v.name, v.score, v.icon) end end end end reset(function(q) question:cleanup() manageCosmetics(frameRef, cosmetics) label.text = default_text if q["display-answer"] then displayAnswer(q) end end) for cat,v in pairs(index.categories) do local c if v.image then c = board:newImageLabel(v.image, 0, 0, 0, 0,(1/#index.categories)*(cat-1),0,1/#index.categories,1/(tiers+1)) else c = board:newTextLabel(v.displayName or v.name,0,0,0,0,(1/#index.categories)*(cat-1),0,1/#index.categories,1/(tiers+1)) c.align = gui.ALIGN_CENTER c.textColor = color.header_font c.color = color.header_tile end c.UUID = multi.generate_uuid7() -- Each otpion gets a unique UUID if not autoreveal then img = c:newImageButton("assets/images/placeholder.jpg") img.visibility = 0 img:OnReleased(function(self) self.visible = false end) img:fullFrame() end c:scaleFont(.25) c:centerFont() for tier = 1,tiers do local t = board:newTextButton("$" .. start + inc*(tier-1),0,0,0,0,(1/#index.categories)*(cat-1),1/(tiers+1)*tier,(1/#index.categories),1/(tiers+1)) t.textColor = color.tile_font t.align = gui.ALIGN_CENTER t.color = color.tile_bg t.category = v.name t.index = tier t.price = start + inc*(tier-1) t:OnEnter(function(self) self:setShader(gui.SHADERS.glow) end) t:OnExit(function(self) self:setShader() end) t:shaderTime(true) t:OnReleased(boardUpdater:newFunction(function(self) if self.text == "" or GetActivePlayer() == nil then return end if dd_enabled then applyDD() -- check and run DD if conditions meet end if index.categories[cat].questions == nil then boardLog:debug("Question not defined: File: %s Category: %s - %s",path,index.categories[cat].name,start + inc*(tier-1)) return end local globals = index.categories[cat].global if not globals then globals = {} end local q = index.categories[cat].questions[self.index] if not q then return end label.text = t.category .. " - " .. t.price boardLog:debug("Displaying question for: %s - %s", t.category, t.price) local mt = debug.getmetatable(q) if mt then mt.__metatable = nil end setmetatable(q, {__index = globals}) if q == nil then boardLog:debug("Question contains no data: File: %s Category: %s Tier: %s",path,index.categories[cat].name,start + inc*(tier-1)) return end if q.cosmetics then manageCosmetics(frameRef, q.cosmetics) end self.textVisibility = 0 local template = LoadTemplate(q.template, path) question.visible = true local player = GetActivePlayer() local tm local stop boardLog:info("Question: '%s' Answer: '%s'",q["title"],q["answer"]) local mul = 1 boardUpdater:newThread(function() if self.isDouble then mul = 2 double:play() dailydouble.visible = true thread.hold(function() return not double:isPlaying() end) dailydouble.visible = false end if q["time-limit"] then tm = timer.startTimer({duration = q["time-limit"]}) tm.OnStop(function() -- Make sound? Subtract if daily double if stop then return end timesup:play() end) end local finished = false template.index(question, q, function(ans, m) local m = m or 1 if finished then return end player = GetActivePlayer() if tm then tm:Cleanup() end stop = true if ans then player:Add((self.price*mul)*m) finished = true question.visible = false reset:Fire(q) elseif ans == false then player:Add(-self.price*mul) player = GetNextPlayer() else finished = true question.visible = false reset:Fire(q) end -- nil is a valid option where you weren't right or wrong, you just skipped self.text = "" end) boardUpdater:newThread("QuestionUpdater",function() while true do template.update(dt) thread.sleep(.1) if self.text == "" then return end end end) end) end)) t:centerFont() t:scaleFont(.5) end end return background end function GetActivePlayer() if not activePlayer then return end return activePlayer.link end local function GetPlayerPos() for i,v in pairs(playerStaticList) do if v == GetActivePlayer() then return i end end end function GetNextPlayer() local pos = GetPlayerPos() if pos >= #playerStaticList then activePlayer = playerStaticList[1].Ref.Frame else activePlayer = playerStaticList[pos + 1].Ref.Frame end scoreboard:RenderPlayer(playerList) return GetActivePlayer() end function love.filedropped(file) file:open("r") local data = file:read() print("Load file? " .. file:getFilename()) end function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh) -- Colors color.indexColor("scoreboard_panel_bg", "#1a1a2e") color.indexColor("scoreboard_header_bg", "#16213e") color.indexColor("scoreboard_accent_color", "#e94560") color.indexColor("scoreboard_row_top_bg", "#1c2641") color.indexColor("scoreboard_row_norm_bg", "#121226") color.indexColor("scoreboard_border_top_color", "#323c6e") color.indexColor("scoreboard_border_nrm_color", "#1c1c37") color.indexColor("scoreboard_bar_empty_color", "#232341") color.indexColor("scoreboard_text_muted_color", "#786e96") color.indexColor("scoreboard_pure_white", "#ffffff") color.indexColor("scoreboard_precious_gold", "#ffd700") color.indexColor("scoreboard_precious_silver", "#C0C0C0") color.indexColor("scoreboard_precious_bronze", "#cd7f32") local C_BG_PANEL = color.scoreboard_panel_bg local C_BG_HEADER = color.scoreboard_header_bg local C_ACCENT = color.scoreboard_accent_color local C_ROW_TOP = color.scoreboard_row_top_bg local C_ROW_NORM = color.scoreboard_row_norm_bg local C_BORDER_TOP = color.scoreboard_border_top_color local C_BORDER_NRM = color.scoreboard_border_nrm_color local C_BAR_EMPTY = color.scoreboard_bar_empty_color local C_TEXT_MUTED = color.scoreboard_text_muted_color local C_WHITE = color.scoreboard_pure_white local C_GOLD = color.scoreboard_precious_gold local C_SILVER = color.scoreboard_precious_silver local C_BRONZE = color.scoreboard_precious_bronze -- Config stuff local LEADER_HEIGHT_SCALE = .06 local HEIGHT_SCALE = .03 local PLAYER_HEIGHT = .05 -- local rankColors = { C_GOLD, C_SILVER, C_BRONZE } local leaderboard = frame:newFrame(x, y, w, h, sx, sy, sw, sh) leaderboard.color = C_BORDER_NRM local headernum = leaderboard:newTextLabel("#",0,0,0,0,0,LEADER_HEIGHT_SCALE,1/6,HEIGHT_SCALE) headernum.align = gui.ALIGN_CENTER local headerplayer = leaderboard:newTextLabel("PLAYER",0,0,0,0,1/6,LEADER_HEIGHT_SCALE,3/6,HEIGHT_SCALE) headerplayer.align = gui.ALIGN_LEFT local headerscore = leaderboard:newTextLabel("SCORE",0,0,0,0,4/6,LEADER_HEIGHT_SCALE,2/6,HEIGHT_SCALE) headerscore.align = gui.ALIGN_RIGHT local header = leaderboard:newTextLabel("LEADERBOARD",0,0,0,0,0,0,1,LEADER_HEIGHT_SCALE) header.borderColor = C_ACCENT header.textColor = C_ACCENT header.color = C_BG_HEADER header.align = gui.ALIGN_CENTER local BASE_HEIGHT = LEADER_HEIGHT_SCALE + HEIGHT_SCALE gui.apply({ drawBorder = false, color = color.new("#0f2a60"), textColor = C_TEXT_MUTED, }, headernum, headerplayer, headerscore) local updateList = {headernum, headerplayer, headerscore, header} for _,object in pairs(updateList) do object:scaleFont(2/3) object:centerFont() end function scoreboard:AddPlayer(name, score, icon) local player = { Name = name, Score = score or 0, Icon = icon, UUID = multi.generate_uuid7(), Add = function(self, amt) self.Score = tostring(tonumber(self.Score) + amt) table.sort(playerList, function(a, b) if a.Score == b.Score then return a.Name < b.Name end return tonumber(a.Score) > tonumber(b.Score) end) scoreboard:RenderPlayer(playerList) end, } table.insert(playerList, player) table.insert(playerStaticList, player) scoreboard:RenderPlayer(playerList) return player end local colors = {C_GOLD, C_SILVER, C_BRONZE} local function MapColor(index) if index <=3 and index > 0 then return colors[index] else return C_WHITE end end local add_player = leaderboard:newFrame(5,-5,-10,0,0,1-PLAYER_HEIGHT,1,PLAYER_HEIGHT) local edit_player = leaderboard:newFrame(5,-10,-10,0,0,1-2*PLAYER_HEIGHT,1,PLAYER_HEIGHT) local remove_player = leaderboard:newTextButton("Remove Selected",5,-15,-10,0,0,1-3*PLAYER_HEIGHT,1,PLAYER_HEIGHT) remove_player.align = gui.ALIGN_CENTER local embededWatch = {remove_player} gui.apply({ centerFont = {}, scaleFont = {.5} },add_player,edit_player,remove_player) local function embedTextEdit(reference, default, but_text, callback) reference.color = C_BORDER_NRM local textbox = reference:newTextBox(default,0,0,0,0,.015,.1,.8,.8) textbox.textColor = C_GOLD textbox.blink = false textbox.color = C_BORDER_TOP textbox.textColor = C_WHITE textbox:OnPressed(function() textbox.text = "" end) local button = reference:newTextButton(but_text,5,0,-10,0,.815,.1,.185,.8) button.color = color.new("#7eae5b") button:OnReleased(function() callback(textbox) end) gui.apply({ scaleFont = {.5}, align = gui.ALIGN_CENTER, centerFont = {}, },textbox,button,reference) end remove_player.color = color.new("#a13a3a") remove_player:OnReleased(function() local player = GetActivePlayer() uuid = player.UUID for i = 1, #playerList do if playerList[i].UUID == uuid then table.remove(playerList,i) break end end for i = 1, #playerStaticList do if playerStaticList[i].UUID == uuid then table.remove(playerStaticList,i) break end end scoreboard:RenderPlayer(playerList) player.Ref.Frame:destroy() end) embedTextEdit(add_player, "Player Name", "Add", function(self) scoreboard:AddPlayer(self.text, "0") end) embedTextEdit(edit_player, "Modify Score", "Edit", function(self) local player = GetActivePlayer() if player then player.Score = self.text scoreboard:RenderPlayer(playerList) end end) function scoreboard:removeAllPlayers() playerList = {} playerStaticList = {} end function scoreboard:RenderPlayer(list) for index, player in ipairs(list) do if player.Ref then player.Ref[1].text = player.Name or "" player.Ref[2].text = player.Score or "" player.Ref[3].text = tostring(index) player.Ref.Frame:setDualDim(nil,5*index,nil,nil,nil,BASE_HEIGHT + (index-1) * PLAYER_HEIGHT) player.Ref.Frame.link = player gui.apply({ visibility = 0, drawBorder = false, textColor = MapColor(index) }, unpack(player.Ref)) if activePlayer == nil then activePlayer = player.Ref.Frame end if player.Ref.Frame == activePlayer then player.Ref.Frame.borderColor = C_BORDER_NRM player.Ref.Frame.color = C_ROW_NORM else player.Ref.Frame.borderColor = C_BORDER_TOP player.Ref.Frame.color = C_ROW_TOP end else local playernum, playerName, playerIcon, playerScore, playerLine local playerFrame = leaderboard:newFrame(5,5*index,-10,0,0,BASE_HEIGHT + (index-1) * PLAYER_HEIGHT,1,PLAYER_HEIGHT) playerFrame:OnReleased(function(self) activePlayer = self scoreboard:RenderPlayer(playerList) end) playerFrame:respectHierarchy(false) playerFrame.borderColor = C_BORDER_TOP playerFrame.color = C_ROW_TOP playernum = playerFrame:newTextLabel(index,0,0,0,0,.015,.1,1/8,.8) playernum.align = gui.ALIGN_CENTER if player.Icon ~= nil then playerIcon = playerFrame:newImageLabel(player.Icon,0,0,0,0,.16,.1,.1,.8) playerIcon.square = "h" -- When working with scales squaring is trickier. (h/w) to switch on width or height playerName = playerFrame:newTextLabel(player.Name,0,0,0,0,.3,0,2/5,.8) playerName.align = gui.ALIGN_LEFT playerLine = playerFrame:newFrame(0,0,0,0,.3,.8,.69,.07) playerLine.color = C_GOLD else playerName = playerFrame:newTextLabel(player.Name,0,0,0,0,.16,0,7/13,.8) playerName.align = gui.ALIGN_LEFT playerLine = playerFrame:newFrame(0,0,0,0,.16,.8,7/13 + 2/7,.07) playerLine.color = C_GOLD end playerScore = playerFrame:newTextLabel(player.Score,0,0,0,0,.71,0,2/7,.8) playerScore.align = gui.ALIGN_CENTER playerLine.drawBorder = false gui.apply({ visibility = 0, drawBorder = false, textColor = MapColor(index), scaleFont = {5/6}, centerFont = {} },playernum, playerName, playerScore, playerIcon, playerLine) player.Ref = {playerName, playerScore, playernum, playerIcon, playerLine, Frame = playerFrame} end end end return scoreboard end return menu.registerMenu("board", Menu)