Files
jeopardy/main.lua
T
2026-10-11 09:16:08 -07:00

577 lines
20 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
local logger = require("utils/logger")
local lovehandler = require("utils/love_file_handler")
local zip = require("utils/zip")
local multi, thread = require("multi"):init({priority=true})
multi.setClock(require("socket").gettime) -- When on linux os.clock doesn't reture actual seconds the program has elapsed for
local GLOBAL, THREAD = require("multi.integration.loveManager"):init()
local fileHandler, logCtrl = lovehandler.new("logs/jeopardy.log", {
max_size = 512 * 1024, -- rotate at 512 KB
max_files = 3,
buffered = false, -- write every line immediately (safest for games)
})
lovehandler.auto_flush_on_quit(logCtrl)
log = logger.new("jeopardy", {
level = logger.LEVELS.INFO,
handlers = {
logger.handlers.ConsoleHandler({ colours = true }),
fileHandler,
},
})
local gui = require("gui")
local color = require("gui.core.color")
color.indexColor("bg","#2b348b")
color.indexColor("tile_bg","#5459e4")
color.indexColor("header_tile","#2a2e91")
color.indexColor("status_font", "#e4b82a")
color.indexColor("title_font","#b16d24")
color.indexColor("question_bg","#363ac8")
color.indexColor("tile_font","#ffffff")
color.indexColor("header_font","#fef6eeda")
color.indexColor("correct", "#52b11b")
color.indexColor("skip", "#5d5d5d")
color.indexColor("wrong", "#bd2626")
color.indexColor("shaderColA","rgb01(0.10, 0.12, 0.38)")
color.indexColor("shaderColB","rgb01(0.20, 0.22, 0.58)")
color.indexColor("shaderColC","rgb01(0.55, 0.58, 0.78)")
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")
color.indexColor("timer_start","#12a217")
color.indexColor("timer_font","#000000")
color.indexColor("timer_warn","#ffff14")
color.indexColor("timer_end","#e50000")
color.indexColor("question_font","rgb(255,255,255)")
local title = require("menus.title")
local menu = require("gui.core.menus")
require("gui.addons")
gui:setHotKey({"f11"})(function()
fullscreen, fstype = love.window.getFullscreen()
love.window.setFullscreen(not fullscreen, "desktop")
end)
local loadLog = log:child("load")
local old = multi.error
-- multi.error = function(self, err)
-- loadLog:error("Error: %s", err)
-- loadLog:error(debug.traceback())
-- love.quit()
-- end
function readFileData(path)
return io.open(path,"r"):read("*a")
end
local picker = THREAD:newFunction(function()
local filepicker = require("utils/filepicker")
local path, err = filepicker.open_file{ title = "Select a file" }
if path then
return path
else
return nil, "No file selected." .. (err and (" (" .. err .. ")") or "")
end
end, true)
local nof = gui.noOf
function gui:newNumberPicker(x, y, w, h, sx, sy, sw, sh, opts)
opts = opts or {}
opts.value = opts.value or 0
opts.inc = opts.inc or 1
opts.decimals = opts.decimals or 0 -- 0 = integers only
-- opts.min / opts.max / opts.onChange are optional, leave nil for "no limit"
local picker = self:newFrame(x, y, w, h, sx, sy, sw, sh)
picker:setRoundness(6, 6)
picker.active = false
local leftF = picker:newFrame(0, 0, 0, 0, 0, 0, .15, 1)
local left = leftF:newTextButton("-", 0, 0, 0, 0, 0, 0, 1, 1)
local rightF = picker:newFrame(0, 0, 0, 0, .85, 0, .15, 1)
local right = rightF:newTextButton("+", 0, 0, 0, 0, 0, 0, 1, 1)
local textbox = picker:newTextBox("", 0, 0, 0, 0, .15, 0, .7, 1)
textbox.blink = false
gui.apply({
scaleFont = {.45},
centerFont = {},
run = {function(self)
if self.text == "-" or self.text == "+" then
self:centerX(true)
self:centerY(true) -- also center vertically, not just horizontally
end
end},
align = gui.ALIGN_CENTER,
}, textbox, left, right)
gui.apply({
visibility = 0,
drawBorder = false, -- flat, no boxy outline around the arrows
}, left, right, leftF, rightF)
-- ---- value handling ----------------------------------------------
local function clamp(n)
if opts.min and n < opts.min then n = opts.min end
if opts.max and n > opts.max then n = opts.max end
return n
end
local function round(n)
if opts.decimals > 0 then
local mult = 10 ^ opts.decimals
return math.floor(n * mult + 0.5) / mult
end
return math.floor(n + 0.5)
end
local function format(n)
if opts.decimals > 0 then
return string.format("%." .. opts.decimals .. "f", n)
end
return tostring(math.floor(n))
end
local function updateButtons()
local canDec = (opts.min == nil) or (opts.value > opts.min)
local canInc = (opts.max == nil) or (opts.value < opts.max)
left.active, right.active = canDec, canInc
left.textColor = canDec and {0, 0, 0, 1} or {.6, .6, .6, 1}
right.textColor = canInc and {0, 0, 0, 1} or {.6, .6, .6, 1}
end
function picker:setValue(n, silent)
n = round(clamp(n))
opts.value = n
textbox.text = format(n)
textbox.cur_pos = #textbox.text
updateButtons()
if not silent and opts.onChange then opts.onChange(n) end
end
function picker:getValue()
return opts.value
end
picker:setValue(opts.value, true)
left:OnReleased(function() picker:setValue(opts.value - opts.inc) end)
right:OnReleased(function() picker:setValue(opts.value + opts.inc) end)
textbox:OnWheelMoved(function(_,dx, dy)
if dy > 0 then picker:setValue(opts.value + opts.inc)
elseif dy < 0 then picker:setValue(opts.value - opts.inc) end
end)
-- ---- restrict typing to numbers only -------------------------------
local allowNegative = opts.min == nil or opts.min < 0
local allowDecimal = opts.decimals > 0
local function sanitize(s)
local out, seenDot = {}, false
for i = 1, #s do
local ch = s:sub(i, i)
if ch:match("%d") then
out[#out + 1] = ch
elseif ch == "-" and i == 1 and allowNegative then
out[#out + 1] = ch
elseif ch == "." and allowDecimal and not seenDot then
out[#out + 1] = ch
seenDot = true
end
end
return table.concat(out)
end
local tiRef = gui.Events.OnTextInputed(function(input)
if gui:getObjectFocus() ~= textbox then return end
local clean = sanitize(textbox.text)
if clean ~= textbox.text then
textbox.text = clean
textbox.cur_pos = math.min(textbox.cur_pos, #clean)
end
end)
local kpRef = gui.Events.OnKeyPressed(function(key)
if gui:getObjectFocus() ~= textbox then return end
if key == "up" then picker:setValue(opts.value + opts.inc)
elseif key == "down" then picker:setValue(opts.value - opts.inc) end
end)
-- re-normalize (round/clamp/reformat) whatever was typed once they
-- hit enter or click away, so "3." or "-" don't linger as invalid text
local function commit()
picker:setValue(tonumber(textbox.text) or opts.value)
end
textbox:OnReturn(commit)
textbox:OnPressedOuter(commit)
textbox:OnDestroy(function()
gui.Events.OnTextInputed:Unconnect(tiRef)
gui.Events.OnKeyPressed:Unconnect(kpRef)
end)
return picker
end
-- ---- HSV <-> RGB helpers ----
local function hsvToRgb(h, s, v)
local c = v * s
local x = c * (1 - math.abs((h / 60) % 2 - 1))
local m = v - c
local r, g, b
if h < 60 then r, g, b = c, x, 0
elseif h < 120 then r, g, b = x, c, 0
elseif h < 180 then r, g, b = 0, c, x
elseif h < 240 then r, g, b = 0, x, c
elseif h < 300 then r, g, b = x, 0, c
else r, g, b = c, 0, x end
return r + m, g + m, b + m
end
local function rgbToHsv(r, g, b)
local max, min = math.max(r, g, b), math.min(r, g, b)
local d = max - min
local h = 0
if d ~= 0 then
if max == r then h = ((g - b) / d) % 6
elseif max == g then h = (b - r) / d + 2
else h = (r - g) / d + 4 end
h = h * 60
end
local s = (max == 0) and 0 or (d / max)
return h, s, max
end
local function clampNum(n, lo, hi) return math.max(lo, math.min(hi, n)) end
local function newCornerGradient(tl, tr, br, bl)
return love.graphics.newMesh({
{0, 0, 0, 0, tl[1], tl[2], tl[3], tl[4]},
{1, 0, 0, 0, tr[1], tr[2], tr[3], tr[4]},
{1, 1, 0, 0, br[1], br[2], br[3], br[4]},
{0, 1, 0, 0, bl[1], bl[2], bl[3], bl[4]},
}, "fan")
end
local _whiteFade, _blackFade
local function getFadeImages()
if not _whiteFade then
-- opaque white on the left fading to transparent on the right
_whiteFade = newCornerGradient(
{1,1,1,1}, {1,1,1,0}, {1,1,1,0}, {1,1,1,1}
)
-- transparent at top fading to opaque black at bottom
_blackFade = newCornerGradient(
{0,0,0,0}, {0,0,0,0}, {0,0,0,1}, {0,0,0,1}
)
end
return _whiteFade, _blackFade
end
function gui:newColorPicker(x, y, w, h, sx, sy, sw, sh, opts)
opts = opts or {}
local r0 = (opts.r or 255) / 255
local g0 = (opts.g or 0) / 255
local b0 = (opts.b or 0) / 255
local picker = self:newFrame(x, y, w, h, sx, sy, sw, sh)
picker.active = false -- container only; don't compete for focus with its children
picker.drawBorder = false
picker.visibility = 0
local hue, sat, val = rgbToHsv(r0, g0, b0)
local hueR, hueG, hueB = hsvToRgb(hue, 1, 1)
-- ---- Saturation/Value square ----
local svFrame = picker:newFrame(6, 6, -12, -12, 0, 0, .55, 1)
svFrame.drawBorder = false
svFrame.visibility = 0
local whiteFade, blackFade = getFadeImages()
svFrame.post = function(self)
local sx2, sy2, sw2, sh2 = self:getAbsolutes()
love.graphics.setColor(hueR, hueG, hueB, 1)
love.graphics.rectangle("fill", sx2, sy2, sw2, sh2)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(whiteFade, sx2, sy2, 0, sw2, sh2)
love.graphics.setColor(1, 1, 1, .95)
love.graphics.draw(blackFade, sx2, sy2, 0, sw2, sh2)
love.graphics.setColor(0, 0, 0, .4)
love.graphics.rectangle("line", sx2, sy2, sw2, sh2)
love.graphics.setColor(1, 1, 1, 1)
end
local svThumb = svFrame:newFrame(0, 0, 0, 0)
svThumb:makeCircle(0, 0, 6)
svThumb.color = {1, 1, 1}
svThumb.borderColor = {0, 0, 0}
svThumb.drawBorder = true
-- ---- Hue strip ----
local hueSlider = picker:newFrame(0, 6, 0, -12, .57, 0, .10, 1)
-- applyGradient's "horizontal"/"vertical" naming is backwards from what
-- you'd expect visually (it names how colors are laid out in the source
-- image, not the on-screen direction) -- if this renders sideways for
-- you, just flip the string to "vertical".
hueSlider:applyGradient("horizontal",
{1, 0, 0, 1}, {1, 1, 0, 1}, {0, 1, 0, 1},
{0, 1, 1, 1}, {0, 0, 1, 1}, {1, 0, 1, 1}, {1, 0, 0, 1})
hueSlider.drawBorder = false
local hueThumb = hueSlider:newFrame(0, 0, 0, 4, 0, 0, 1, 0)
hueThumb.color = {1, 1, 1}
hueThumb.borderColor = {0, 0, 0}
hueThumb.drawBorder = true
-- ---- Preview + numeric fields ----
local preview = picker:newFrame(6, 6, -12, -6, .70, 0, .30, .20)
preview:setRoundness(4, 4)
local rPicker, gPicker, bPicker
local function refresh(fireChange)
local r, g, b = hsvToRgb(hue, sat, val)
hueR, hueG, hueB = hsvToRgb(hue, 1, 1)
preview.color = {r, g, b}
local _, _, sw2, sh2 = svFrame:getAbsolutes()
svThumb:rawSetDualDim(sat * sw2 - 12, (1 - val) * sh2 - 6)
local _, _, _, hh2 = hueSlider:getAbsolutes()
hueThumb:rawSetDualDim(nil, (hue / 360) * hh2 - 2)
local r255 = math.floor(r * 255 + .5)
local g255 = math.floor(g * 255 + .5)
local b255 = math.floor(b * 255 + .5)
if rPicker then
rPicker:setValue(r255, true)
gPicker:setValue(g255, true)
bPicker:setValue(b255, true)
end
if fireChange ~= false and opts.onChange then
opts.onChange(r255, g255, b255)
end
end
local function onRGBEdited()
local r = rPicker:getValue() / 255
local g = gPicker:getValue() / 255
local b = bPicker:getValue() / 255
hue, sat, val = rgbToHsv(r, g, b)
refresh(true)
end
rPicker = picker:newNumberPicker(6, 0, -12, -6, .70, .26, .30, .20,
{value = math.floor(r0 * 255 + .5), min = 0, max = 255, onChange = onRGBEdited})
gPicker = picker:newNumberPicker(6, 0, -12, -6, .70, .52, .30, .20,
{value = math.floor(g0 * 255 + .5), min = 0, max = 255, onChange = onRGBEdited})
bPicker = picker:newNumberPicker(6, 0, -12, -6, .70, .78, .30, .20,
{value = math.floor(b0 * 255 + .5), min = 0, max = 255, onChange = onRGBEdited})
gui.apply({
scaleFont={.1}
},rPicker,gPicker,bPicker)
-- ---- drag interaction (same press/move/release pattern the built-in
-- textbox uses for its own text-selection dragging) ----
local function setSV(mx, my)
local sx2, sy2, sw2, sh2 = svFrame:getAbsolutes()
local lx = clampNum(mx - sx2, 0, sw2)
local ly = clampNum(my - sy2, 0, sh2)
sat = sw2 > 0 and (lx / sw2) or 0
val = sh2 > 0 and (1 - ly / sh2) or 0
refresh(true)
end
local svDragging = false
svFrame:OnPressed(function(_, mx, my) svDragging = true; setSV(mx, my) end)
svFrame:OnMoved(function(_, mx, my) if svDragging then setSV(mx, my) end end)
svFrame:OnReleased(function() svDragging = false end)
svFrame:OnReleasedOuter(function() svDragging = false end)
local function setHue(mx, my)
local _, sy2, _, sh2 = hueSlider:getAbsolutes()
local ly = clampNum(my - sy2, 0, sh2)
hue = sh2 > 0 and (ly / sh2) * 360 or 0
refresh(true)
end
local hueDragging = false
hueSlider:OnPressed(function(_, mx, my) hueDragging = true; setHue(mx, my) end)
hueSlider:OnMoved(function(_, mx, my) if hueDragging then setHue(mx, my) end end)
hueSlider:OnReleased(function() hueDragging = false end)
hueSlider:OnReleasedOuter(function() hueDragging = false end)
-- ---- public API ----
function picker:getColor()
local r, g, b = hsvToRgb(hue, sat, val)
return math.floor(r * 255 + .5), math.floor(g * 255 + .5), math.floor(b * 255 + .5)
end
function picker:setColor(r255, g255, b255)
hue, sat, val = rgbToHsv(r255 / 255, g255 / 255, b255 / 255)
refresh(false)
end
refresh(false)
return picker
end
function love.load()
love.keyboard.setKeyRepeat(true)
-- local z = zip.new()
-- z:add("main.lua",readFileData("main.lua"))
-- z:add("package.bat",readFileData("package.bat"))
-- local data = z:build()
-- local file = io.open("test.zip","wb")
-- file:write(data)
-- file:close()
-- thread:newThread(function()
-- local file = picker()
-- print("File selected",file)
-- end).OnError(print)
-- local numpicker = gui:newNumberPicker(0, 0, 200, 40, .5, .5, 0, 0, {
-- value = 1, inc = 1, min = 1, max = 4,
-- onChange = function(v) print("value is now", v) end,
-- })
local frame = gui:newFrame(0, 0, 500, 250, .5, .5, 0, 0)
local colorpicker = frame:newColorPicker(0, 0, 0, 0, 0, 0, 1, 1, {
r = 255, g = 100, b = 50,
onChange = function(r, g, b) print("color:", r, g, b) end,
})
--[[ -- bootstrap
loadLog:info("Starting Jeopardy Game")
loadLog:debug("Setting game identity to jeopardy")
for i,v in ipairs(color.listColors(showAllColors)) do
loadLog:debug("Indexed color %s",v)
end
love.filesystem.setIdentity("jeopardy")
loadLog:trace("Creating save directory")
success = love.filesystem.createDirectory("")
if not success then
loadLog:fatal("Unable to create save directory")
love.quit()
end
loadLog:trace("Creating save directory")
gui:cacheImage({"assets/images/checked.png","assets/images/unchecked.png","assets/images/speaker.png"})
gui:setAspectSize(1920, 1080)
gui.aspect_ratio = true
loadLog:debug("Creating main background image")
local bg = gui:newImageLabel("assets/images/background.png")
bg:fullFrame()
loadLog:debug("Init menu module")
menu.init(bg)
loadLog:debug("Displaying title menu")
title:visible(true)
]]
end
function love.update(dt)
gui.update(dt)
multi:uManager(dt)
end
function love.draw()
gui.draw()
end
-- custom shaders
gui.NewShader("background", [[
extern float time;
extern vec2 size;
extern vec3 colA;
extern vec3 colB;
extern vec3 colC;
// Hash function (pseudo-random from 2D input)
float hash(vec2 p) {
p = fract(p * vec2(127.1, 311.7));
p += dot(p, p + 19.19);
return fract(p.x * p.y);
}
// Smooth value noise
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
// Quintic smoothstep
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
// Fractional Brownian Motion – layers of noise at different scales
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 6; i++) {
value += amplitude * noise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
vec4 effect(vec4 color, Image tex, vec2 texCoord, vec2 screenCoord) {
// Normalized UV with aspect ratio correction
vec2 uv = screenCoord / size;
// Slow, asymmetric time offsets per axis for lava-lamp feel
float t1 = time * 0.008;
float t2 = time * 0.005;
// Domain-warped FBM: feed FBM output back into itself
// This creates the bulging, self-folding blob shapes.
vec2 q = vec2(
fbm(uv + vec2(0.0, 0.0) + t1),
fbm(uv + vec2(5.2, 1.3) + t2)
);
vec2 r = vec2(
fbm(uv + 4.0 * q + vec2(1.7, 9.2) + t1 * 0.7),
fbm(uv + 4.0 * q + vec2(8.3, 2.8) + t2 * 0.9)
);
float n = fbm(uv + 4.0 * r + t1 * 0.4);
vec3 col = mix(colA, colB, clamp(n * 2.0, 0.0, 1.0));
col = mix(col, colC, clamp(n * 2.0 - 0.6, 0.0, 1.0));
return vec4(col, 1.0);
}
]])