Files
jeopardy/menus/board.lua
T

827 lines
28 KiB
Lua

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()
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
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 cosmetics["bg-img"] then
frame:setImage(cosmetics["bg-img"])
else
frame:setImage("assets/images/background.png")
end
if type(cosmetics.colors) == "table" then
for i,v in pairs(cosmetics.colors) do
color.reindexColor((i:gsub("-","_")),v)
end
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:newFrame():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()
boardLog:debug("Cleaning up state and returning to title screen")
scoreboard:removeAllPlayers()
cleanUp()
frameRef:destroy()
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.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,
OnReleased=function(self)
if self.text == "Skip" then
handler_conn:Fire()
reference:Unconnect()
handler.visible = false
return
end
local val = self.text == "Correct"
print(multiply)
handler_conn:Fire(val, multiply)
if val then
reference:Unconnect()
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 {}
manageCosmetics(frame, 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(frame, 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: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(frame, 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)