Files
jeopardy/menus/board.lua
T

725 lines
25 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 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 = {}
-- Create a table to manage GUI elements
local manage = {}
local boardLog = log:child("board")
-- Function to resize fonts for all managed elements
boardLog:debug("Creating resize fonts handler")
local function resizeFonts()
-- Use multi-threading to improve performance
thread.skip(2)
-- Iterate over each element in the 'manage' table
for i = 1, #manage do
local elem = manage[i]
-- Check if the current element has a centerFont property
if elem.centerFont then
-- Adjust font size and re-center the text
elem:fitFont(nil, nil, {scale = 2/3})
elem:centerFont()
end
end
end
gui.Events.OnResized(resizeFonts)
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 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 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,
-- 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
local reset = boardUpdater:newConnection()
local default_text = "Pick a Category"
function Menu(frame, path)
boardLog:info("Building board")
if path:match(".zip") then
-- local basename = path:match("([^/\\]+)$")
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
-- Start the processors now
boardLog:debug("Starting processors")
boardUpdater.Start()
qUpdater.Start()
scoreUpdater.Start()
local scoreboard = ScoreBoard(frame, 0, 0, 0, 0, .06, .1, .170, .8)
local qframe = frame:newFrame(0, 0, 0, 0, .24, .1, .70, .8)
local label = frame:newTextLabel(default_text, 0, 10, 0, -20, .29, 0, .6, .1)
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)
local updateLabel = function()
label:fitFont(nil, nil, {scale = 2/3})
label:centerFont()
end
boardUpdater:newThread(updateLabel)
gui.Events.OnResized(updateLabel)
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()
question:cleanup()
manageCosmetics(frame, cosmetics, true)
label.text = default_text
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
table.insert(manage,c)
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:info("Question not defined: File: %s Category: %s - %s\n",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:info("Question contains no data: File: %s Category: %s Tier: %s\n",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()
elseif ans == false then
player:Add(-self.price*mul)
player = GetNextPlayer()
else
finished = true
question.visible = false
reset:Fire()
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))
table.insert(manage,t)
end
end
resizeFonts()
resizeFonts()
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 = {header, headernum, headerplayer, headerscore}
local function ScoreResize()
scoreUpdater:newThread(function()
thread.skip(2)
for _,object in pairs(updateList) do
object:fitFont(nil, nil, {scale = 5/6})
object:centerFont()
end
for _,object in pairs(playerList) do
if type(object) == "table" and object.Ref then
for i, player in pairs(object.Ref) do
if player:hasType(gui.TYPE_TEXT) then
player:fitFont(nil, nil, {scale = 5/6})
player:centerFont()
end
end
end
end
end)
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)
ScoreResize()
end,
}
table.insert(playerList, player)
table.insert(playerStaticList, player)
scoreboard:RenderPlayer(playerList)
ScoreResize()
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:setFont(20)
remove_player.align = gui.ALIGN_CENTER
local embededWatch = {remove_player}
gui.Events.OnMousePressed(boardUpdater:newFunction(function()
for i,v in pairs(embededWatch) do
v:centerFont()
v:centerFont()
end
end))
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({
setFont = {20},
align = gui.ALIGN_CENTER
},textbox,button,reference)
table.insert(embededWatch,textbox)
table.insert(embededWatch,button)
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: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)
},playernum, playerName, playerScore, playerIcon, playerLine)
player.Ref = {playerName, playerScore, playernum, playerIcon, playerLine, Frame = playerFrame}
end
end
end
gui.Events.OnResized(ScoreResize)
ScoreResize()
return scoreboard
end
return menu.registerMenu("board", Menu)