7 Commits

78 changed files with 1263 additions and 11980 deletions
+3
View File
@@ -0,0 +1,3 @@
bin/*
love/*
game.love
+3
View File
@@ -1,3 +1,6 @@
[submodule "multi"]
path = multi
url = https://github.com/rayaman/multi.git
[submodule "gui"]
path = gui
url = https://github.com/rayaman/gui
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
"runtime.version": "LuaJIT",
"diagnostics.globals": [
"love"
],
"workspace.library": [
"${3rd}/love2d/library"
],
"workspace.checkThirdParty": false
}
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations":
["tomblind.local-lua-debugger-vscode",
"sumneko.lua",]
}
+28
View File
@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "lua-local",
"request": "launch",
"name": "Debug",
"program": {
"command": "love"
},
"args": [
".",
"debug"
],
},
{
"type": "lua-local",
"request": "launch",
"name": "Release",
"program": {
"command": "love"
},
"args": [
".",
],
},
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build with LÖVE-Build",
"type": "shell",
"command": "love-build", // Path to Love-build
"args": [".main.lua"],
"group": "build",
}
]
}
+1 -1
View File
@@ -1 +1 @@
# ascension-rebirth
# jeopardy game
-20
View File
@@ -1,20 +0,0 @@
settings:
increment: 100
start: 100
tiers: 5
dailyDouble:
enabled: true
minQuestions: 7
count: 2
categories:
- name: shounen
displayName: "Shounen"
- name: villains
displayName: "Villains"
- name: openings
displayName: "Openings"
- name: studios
displayName: "Studios & Staff"
- name: plot-twists
displayName: "Plot Twists"
-33
View File
@@ -1,33 +0,0 @@
questions:
- title: "The iconic opening 'Guren no Yumiya' by Linked Horizon belongs to this anime."
time-limit: 15
answer: "Attack on Titan"
template: "whatis"
- title: "Which anime features the opening 'Unravel' by TK from Ling Tosite Sigure?"
answer: 2
choices:
- "Parasyte"
- "Tokyo Ghoul"
- "Deadman Wonderland"
- "Elfen Lied"
template: "multiplechoice"
- title: "'Cruel Angel's Thesis' is the legendary opening theme of this 1995 mecha anime."
time-limit: 20
answer: "Neon Genesis Evangelion"
template: "whatis"
- title: "Which of these bands performed 'Again', the first opening of Fullmetal Alchemist: Brotherhood?"
answer: 4
choices:
- "Asian Kung-Fu Generation"
- "Flow"
- "Scandal"
- "YUI"
template: "multiplechoice"
- title: "This artist performed 'Gurenge', the first opening of Demon Slayer: Kimetsu no Yaiba, which became a massive hit in Japan."
time-limit: 25
answer: "LiSA"
template: "whatis"
-33
View File
@@ -1,33 +0,0 @@
questions:
- title: "In Code Geass, Lelouch's Geass ability compels anyone who makes direct eye contact with him to do this."
time-limit: 20
answer: "Obey any one order he gives them"
template: "whatis"
- title: "At the end of Madoka Magica, Madoka's wish rewrites the laws of the universe to do what?"
answer: 2
choices:
- "Destroy all witches that have ever existed and will ever exist"
- "Erase all witches before they are born, taking herself out of existence"
- "Turn every magical girl back into a normal human"
- "Destroy Kyubey and the incubator race"
template: "multiplechoice"
- title: "In Fullmetal Alchemist: Brotherhood, Edward and Alphonse discover their mother was not actually revived — what they created during their human transmutation was this."
time-limit: 30
answer: "A soulless body / failed homunculus (not their mother)"
template: "whatis"
- title: "Which of these correctly describes the twist at the end of Assassination Classroom?"
answer: 3
choices:
- "Koro-sensei was always a government weapon and willingly lets himself be killed"
- "The students refuse to kill Koro-sensei and he escapes to space"
- "Koro-sensei asks his students to be the ones to kill him as his final wish"
- "Nagisa kills the minister of defense and Koro-sensei is freed"
template: "multiplechoice"
- title: "In Gurren Lagann, Simon discovers that the key to piloting Lagann is not a special device but actually this."
time-limit: 25
answer: "His own drill / fighting spirit (spiral power)"
template: "whatis"
BIN
View File
Binary file not shown.
-33
View File
@@ -1,33 +0,0 @@
questions:
- title: "This is the name of the signature technique Naruto Uzumaki uses to create physical copies of himself."
time-limit: 15
answer: "Shadow Clone Jutsu"
template: "whatis"
- title: "In Dragon Ball Z, Goku achieved this transformation for the first time during his battle against Frieza on Namek."
time-limit: 20
answer: "Super Saiyan"
template: "whatis"
- title: "Which of these is the name of Ichigo Kurosaki's Zanpakuto in Bleach?"
answer: 3
choices:
- "Zangetsu"
- "Senbonzakura"
- "Tensa Zangetsu"
- "Ryujin Jakka"
template: "multiplechoice"
- title: "In Hunter x Hunter, this is the name of the system of power that allows users to manipulate their life energy."
time-limit: 25
answer: "Nen"
template: "whatis"
- title: "Which of these Devil Fruits did Monkey D. Luffy eat, giving him his rubber powers?"
answer: 2
choices:
- "Mera Mera no Mi"
- "Gomu Gomu no Mi"
- "Hana Hana no Mi"
- "Bara Bara no Mi"
template: "multiplechoice"
-33
View File
@@ -1,33 +0,0 @@
questions:
- title: "This animation studio, co-founded by Hayao Miyazaki, produced Spirited Away, My Neighbor Totoro, and Princess Mononoke."
time-limit: 15
answer: "Studio Ghibli"
template: "whatis"
- title: "Which studio produced Fullmetal Alchemist: Brotherhood, Sword of the Stranger, and Mob Psycho 100?"
answer: 1
choices:
- "Bones"
- "Madhouse"
- "White Fox"
- "A-1 Pictures"
template: "multiplechoice"
- title: "This director is known for helming Cowboy Bebop, Samurai Champloo, and Space Dandy."
time-limit: 25
answer: "Shinichiro Watanabe"
template: "whatis"
- title: "Which studio is responsible for the long-running adaptations of One Piece and Dragon Ball Super?"
answer: 3
choices:
- "Pierrot"
- "Sunrise"
- "Toei Animation"
- "TMS Entertainment"
template: "multiplechoice"
- title: "This legendary animator, known as 'the god of manga', created Astro Boy and is considered the father of modern anime and manga."
time-limit: 20
answer: "Osamu Tezuka"
template: "whatis"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
settings:
increment: 100 # how much each tier increases by
start: 100 # starting amount see tier's comment
tiers: 5 # 5 tiers 100, 200, 300, 400, 500 based on above settings
dailyDouble:
enabled: true # should daily double be enabled?
minQuestions: 7 # how many questions need to be answered before a daily double can appear?
count: 2 # how many daily doubles should there be?
categories: # the name of each category should correspond to a yaml file with the same name
- name: shounen # name must be alphanumeric, cannot start with a number or contain special characters "-" and "." are ok if they are not at the beginning
displayName: "Shounen" # if blank will use name
image: "anime/assets/14018-3193093789.gif" # if set will display image instead of name, categories are referenced by name which is required
- name: openings
displayName: "Openings" # if blank will use name
- name: sadness
displayName: "Sadness" # if blank will use name
- name: random
displayName: Random # if blank will use name
- name: you-would-have-though
displayName: "You would have\nthought" # if blank will use name
- name: wtf-is-that
displayName: "Wtf is that!?" # if blank will use name
- name: owo
displayName: "owo" # if blank will use name
-35
View File
@@ -1,35 +0,0 @@
questions: # expects a list equal to the tier
- title: "Name at least 10 of the characters here"
time-limit: 60 # If omitted there is no time limit
answer: 10
image: "anime/assets/shounen_name_characters.png"
template: "one-image" # template to use
display-answer: true # displays the correct answer before returning to the main screen
- title: "What anime is this?"
time-limit: 30 # If omitted there is no time limit
answer: "steins;gate"
imageA: "anime/assets/beer.jpg"
imageB: "anime/assets/gate.jpg"
display-answer: true # displays the correct answer before returning to the main screen
template: "imageword" # template to use
- title: "What is 2+2"
answer: 2 # positon of correct answer
choices:
- "22"
- "4" # this is 2
- "9000"
- "-4"
- "wack"
template: "multiplechoice"
- title: "Who directed the groundbreaking film 'Akira',\nwhich helped introduce anime to Western audiences?"
time-limit: 10 # If omitted there is no time limit
answer: "Katsuhiro Otomo"
template: "whatis" # template to use
display-answer: true # displays the correct answer before returning to the main screen
- title: "What is this?"
answer: "Bunny shit"
video: "anime/assets/bunny.ogv"
start: 4
playFor: 3
template: "whatvideo" # template to use
display-answer: true # displays the correct answer before returning to the main screen
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

-290
View File
@@ -1,290 +0,0 @@
local gui = require("gui")
local color = require("gui.core.color")
local loader = require("loader")
local theme = require("gui.core.theme")
local fmt = require("fmt")
local timer = require("utils")
local multi, thread = require("multi"):init()
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")
boardUpdater.Start()
local activePlayer
local playerList = {}
local playerStaticList = {}
local scoreboard = {}
-- Create a table to manage GUI elements
local manage = {}
-- Function to resize fonts for all managed elements
local function resizeFonts()
-- Use multi-threading to improve performance
multi:newThread(function()
-- Introduce a small delay to avoid too many iterations at once
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)
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
function gui:cleanup()
for i = #self.children, 1, -1 do
self.children[i]:destroy()
end
self.children = {}
completed_questions = completed_questions + 1
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
local dd = 0
local dd_list = {}
local dds = {pickFiltered(board.children, dd_count)}
for i,v in pairs(dds) do
print(i,v)
v.isDouble = true
end
applied_dd = true
end
end
local function buildBoard(frame, path)
local data = loader:new(path)
board, question, dailydouble = frame:newFrame(), frame:newFrame(), frame:newImageLabel("assets/images/double.jpg")
index = data.index
board:fullFrame()
question:fullFrame()
dailydouble:fullFrame()
dailydouble.visible = false
question.visible = false
question.color = color.new("#060ce9")
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
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.new("#ffffff")
c.color = color.new("#060ce9")
end
c.UUID = multi.generate_uuid7() -- Each otpion gets a unique UUID
img = c:newImageButton("assets/images/placeholder.jpg")
img.visibility = 0
img:OnReleased(function(self)
self.visible = false
end)
img:fullFrame()
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.new("#9b9024")
t.align = gui.ALIGN_CENTER
t.color = color.new("#060ce9")
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 fmt.Printf("Question not defined: File: %v Category: %v - %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end
local q = index.categories[cat].questions[self.index]
if q == nil then fmt.Printf("Question contains no data: File: %v Category: %v Tier: %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end
self.textVisibility = 0
local template = LoadTemplate(q.template, path)
question.visible = true
local player = GetActivePlayer()
local tm
local stop
fmt.Printf("--------------------\nQuestion: %v \nAnswer: %v\n--------------------\n",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)
if finished then return end
player = GetActivePlayer()
if tm then
tm:Cleanup()
end
stop = true
if ans then
player:Add(self.price*mul)
finished = true
question.visible = false
question:cleanup()
elseif ans == false then
player:Add(-self.price*mul)
player = GetNextPlayer()
else
finished = true
question.visible = false
question:cleanup()
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.yield()
if self.text == "" then return end
end
end)
end)
end))
table.insert(manage,t)
end
end
resizeFonts()
resizeFonts()
end
local qUpdater = gui:getProcessor():newProcessor("question-updater")
qUpdater.Start()
function LoadTemplate(name, path)
if love.filesystem.getInfo("templates/" .. name .. ".lua") then
data = love.filesystem.read("templates/" .. name .. ".lua")
elseif love.filesystem.getInfo(path .."/templates/" .. name .. ".lua") then
data = love.filesystem.read(path .."/templates/" .. name .. ".lua")
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
}
env._G = env
local isoTemplate = multi.isolateFunction(template, env)
return isoTemplate()
end
return {
buildBoard = buildBoard
}
+17
View File
@@ -0,0 +1,17 @@
return {
-- basic settings:
name = 'Jeopardy', -- name of the game for your executable
developer = 'Ryan Ward', -- dev name used in metadata of the file
output = 'bin', -- output location for your game, defaults to $SAVE_DIRECTORY
version = '1.0.2', -- 'version' of your game, used to name the folder in output
love = '11.5', -- version of LÖVE to use, must match github releases
ignore = {'bin', 'love', 'server_test', '.gitignore', '.gitmodules', 'build.lua', 'login_dialog.yaml', 'package.bat', 'README.md'}, -- folders/files to ignore in your project
icon = 'assets/icon.png', -- 256x256px PNG icon for game, will be converted for you
-- optional settings:
use32bit = false, -- set true to build windows 32-bit as well as 64-bit
identifier = 'net.ddns.epicknex.jeopardy', -- macos team identifier, defaults to game.developer.name
libs = {},
hooks = {},
platforms = {'windows', --[['macos', 'linux', 'steamdeck']]} -- set if you only want to build for a specific platform
}
+4 -4
View File
@@ -1,9 +1,9 @@
function love.conf(t)
t.identity = nil -- The name of the save directory (string)
t.identity = "Jeopardy"
t.version = "11.5" -- The LOVE version this game was made for (string)
t.console = true -- Attach a console (boolean, Windows only)
t.window.title = "GuiManagerTest" -- The window title (string)
t.window.title = "Jeopardy" -- The window title (string)
t.window.icon = nil -- Filepath to an image to use as the window's icon (string)
t.window.width = 1920 -- The window width (number)
t.window.height = 1080 -- The window height (number)
@@ -13,7 +13,7 @@ function love.conf(t)
t.window.minheight = 1 -- Minimum window height if the window is resizable (number)
t.window.fullscreen = false -- Enable fullscreen (boolean)
t.window.fullscreentype = "desktop" -- Standard fullscreen or desktop fullscreen mode (string)
t.window.vsync = false -- Enable vertical sync (boolean)
t.window.vsync = true -- Enable vertical sync (boolean)
t.window.fsaa = 4 -- The number of samples to use with multi-sampled antialiasing (number)
t.window.display = 1 -- Index of the monitor to show the window in (number)
t.window.highdpi = true -- Enable high-dpi mode for the window on a Retina display (boolean)
@@ -29,7 +29,7 @@ function love.conf(t)
t.modules.keyboard = true -- Enable the keyboard module (boolean)
t.modules.math = true -- Enable the math module (boolean)
t.modules.mouse = true -- Enable the mouse module (boolean)
t.modules.physics = true -- Enable the physics module (boolean)
t.modules.physics = false -- Enable the physics module (boolean)
t.modules.sound = true -- Enable the sound module (boolean)
t.modules.system = true -- Enable the system module (boolean)
t.modules.timer = true -- Enable the timer module (boolean)
Submodule
+1
Submodule gui added at c4cf5cbc9e
-72
View File
@@ -1,72 +0,0 @@
# GuiManager
This library due to the changes in love2d. Too many things are broken and instead of doing patch work, I've decided to do a total rewrite. Also I'll be able to make use of the new multi manager features and build a better library from the ground up.
Core Objects:
- ~~Frame~~ ✔️
- Text:
- ~~Label~~ ✔️
- ~~Box~~ ✔️
- ~~Button~~ ✔️
- utf8 support with textbox (Forgot about this, will have to rework some things)
- Image:
- ~~Label~~ ✔️
- ~~Button~~ ✔️
- Animation
- ~~Video~~ ✔️
Events:
- Mouse Events
- ~~Enter~~ ✔️
- ~~Exit~~ ✔️
- ~~Pressed~~ ✔️
- ~~Released~~ ✔️
- ~~Moved~~ ✔️
- ~~WheelMoved~~ ✔️
- ~~DragStart~~ ✔️
- ~~Dragging~~ ✔️
- ~~DragEnd~~ ✔️
- Keyboard Events
- ~~Hotkey~~ ✔️ Refer to [KeyConstants](https://love2d.org/wiki/KeyConstant) wiki page
- Some default hotkeys have been added:
- ~~(conn)gui.HotKeys.OnSelectAll~~ ✔️ `Ctrl + A`
- ~~(conn)gui.HotKeys.OnCopy~~ ✔️ `Ctrl + C`
- ~~(conn)gui.HotKeys.OnPaste~~ ✔️ `Ctrl + V`
- ~~(conn)gui.HotKeys.OnUndo~~ ✔️ `Ctrl + Z`
- ~~(conn)gui.HotKeys.OnRedo~~ ✔️ `Ctrl + Y, Ctrl + Shift + Z`
- Other Events
- ~~OnUpdate~~ ✔️
- ~~OnDraw~~ ✔️
Polish:
- ~~gui:newCheckbox()~~
- ~~gui:isOnScreen()~~
- ~~gui:newRadioGroup()~~
- gui:newSlider()
- ~~gui:newProgressBar()~~
- gui:newTooltip()
- gui:newTextArea()
- TODO: selection, hotkeys
- gui:newListFrame()
- gui:newGridFrame()
Better Transistions:
- ease.easeIn(start, stop, time) -- accelerates from start
- ease.easeOut(start, stop, time) -- decelerates into stop
- ease.easeInOut(start, stop, time) -- smooth S-curve
- ease.easeInCubic(...) -- more aggressive acceleration
- ease.easeOutCubic(...) -- more aggressive deceleration
- ease.bounce(start, stop, time) -- bounces at the end
- ease.elastic(start, stop, time) -- overshoots then settles
- ease.back(start, stop, time) -- slight pull-back before moving
Focus Management:
- gui.focus.set(element) -- programmatically set focus
- gui.focus.get() -- returns currently focused element (or nil)
- gui.focus.clear() -- clear focus (no element focused)
- gui.focus.setTabOrder(list) -- set a list of elements for Tab navigation
- gui.focus.tabNext() -- focus the next element in the tab order
- gui.focus.tabPrev() -- focus the previous element
Z-index Enhancments:
- gui:setLayer(n)
- gui:getLayer()
-249
View File
@@ -1,249 +0,0 @@
local gui = require("gui")
local theme = require("gui.core.theme")
local color = require("gui.core.color")
local multi, thread = require("multi"):init()
local mediaProc = gui:newProcessor()
local miscProc = gui:newProcessor()
local function noOf(sx,sy,sw,sh)
return nil,nil,nil,nil,sx,sy,sw,sh
end
function gui:newVideoPlayer(source, x, y, w, h, sx, sy, sw, sh, draggable, th)
local window = self:newWindow(x, y, w, h, sx, sy, sw, sh, "", draggable, th or theme:new({
primary = "#000000",
primaryDark = "#10465c",
primaryText = "#ffffff"
}))
local video = window:newVideo(source, 0, 0, 0, 0, 0, .05, 1, .75)
local length = video:getDuration()
local play_pause = window:newImageButton("gui/assets/play.png",0,0,0,0,.45,.86,0,.14)
local seek = window:newProgressBar(0,0,0,0,0,.8,1,.05,length*100,0)
seek.OnProgressUpdated(function(self,_,value, drag)
if drag then
local status = video:isPlaying()
video:seek(value/100)
if status then
video:play()
else
video:stop()
end
end
end)
seek:isClickable(true)
play_pause.square = "h"
play_pause.isPaused = true
play_pause:OnReleased(function(self)
if self.isPaused then
self:setImage("gui/assets/pause.png")
video:play()
else
self:setImage("gui/assets/play.png")
video:pause()
end
self.isPaused = not self.isPaused
end)
mediaProc:newThread(function()
while true do
thread.hold(function() return video:isPlaying() end)
local p = video:tell()
seek:update(p*100)
end
end)
function window:seek(...)
video.video:seek(...)
end
function window:play()
video:play()
end
function window:stop()
video:stop()
end
window.OnClose(function()
video:pause()
end)
return window
end
function gui:newCheckbox(label, x, y, size, sx, sy, checked)
local checkbox = self:newFrame(x, y, size, size, sx, sy)
checkbox.color = color.black
local border = checkbox:newVisualFrame(noOf(.1,.1,.8,.8))
border.color = color.white
local toggle = border:newFrame(noOf(.3,.3,.4,.4))
toggle.color = color.black
toggle.visible = false
checkbox:OnReleased(function()
checkbox:check(not toggle.visible)
end)
if label ~= "" then
local text = checkbox:newTextLabel(label, noOf(1.25,0,15,1))
text:OnUpdate(function()
text:centerFont()
end)
text:setFont(size-2)
text.visibility = 0
end
function checkbox:check(value)
toggle.visible = value
self.OnChanged:Fire(value)
end
function checkbox:isChecked()
return toggle.visible
end
function checkbox:getLabel()
return label or ""
end
checkbox.OnChanged = multi:newConnection()
return checkbox
end
function gui:newRadioGroup(options, x, y, sx, sy, size)
local group = {}
local rg = self:newFrame()
local selected
rg.OnSelectionChanged = multi:newConnection()
for i,v in ipairs(options or {}) do
table.insert(group,self:newCheckbox(tostring(v),x,y+((i-1)*size+((options.padding or 0)*(i-1))),size,sx,sy))
end
gui.apply({
OnReleased=function(self)
gui.apply({check={false}},unpack(group))
self:check(true)
if selected ~= self then
rg.OnSelectionChanged:Fire(rg, self)
end
selected = self
end,
},unpack(group))
function rg:getSelectedOption()
return selected
end
return rg
end
function gui:newProgressBar(x, y, w, h, sx, sy, sw, sh, count, value)
local value = value or 0
local count = count or 100
local progressbar = self:newFrame(x,y,w,h,sx,sy,sw,sh)
local fillframe = progressbar:newFrame(2,2,-4,-4,0,0,1,1)
local fill = fillframe:newFrame(noOf(0, 0, 1, 1))
local percentDisplay = fillframe:newTextLabel("",noOf(0,0,1,1))
percentDisplay.align = gui.ALIGN_CENTER
percentDisplay.textColor = color.new("#CC5500")
fillframe.visibility = 0
progressbar.color = color.new("#000000")
fill.color = color.new("#ffffff")
progressbar.fillframe = fillframe
progressbar.fill = fill
progressbar.display = percentDisplay
progressbar.OnProgressUpdated = multi:newConnection()
percentDisplay.visibility = 0
local displayPercent = false
function progressbar:showPercent(bool)
displayPercent = bool
if bool then
miscProc:newThread(function()
thread.skip(2)
_,_,_h = percentDisplay:getAbsolutes()
percentDisplay:setFont(math.floor(h/1.3))
self:update()
end)
end
end
function progressbar:isClickable(bool)
fillframe:respectHierarchy(not bool)
-- Makes the bar updatable by clicking on it
fillframe:enableDragging(bool and gui.MOUSE_PRIMARY)
end
local calcFunc = function(self, dx, dy, x, y, istouch)
local sx, sy, sw, sh = self:getAbsolutes()
if x >= sx and x <= sx + sw then
progressbar:update((((x - sx)/sw) * count), true)
end
end
fillframe:OnDragStart(calcFunc)
fillframe.OnDragging(calcFunc)
fillframe.OnPressed(function(self, x, y, dx, dy, istouch)
calcFunc(self, dx, dy, x, y, istouch)
end)
function progressbar:update(v, drag)
v = v or value
if v > count then v = count end
if v < 0 then v = 0 end
local percent = value/count
fill:setDualDim(noOf(nil,nil,percent))
if displayPercent then
percentDisplay.text = math.floor((percent*100)+.5).. "%"
percentDisplay:centerFont()
end
value = v
self.OnProgressUpdated:Fire(self, percent, value, drag)
end
function progressbar:add(n)
if value >= count then
return
end
value = value + n
self:update(value)
end
function progressbar:sub(n)
if value <= 0 then
return
end
value = value - n
self:update(value)
end
function progressbar:max()
self:update(count)
end
function progressbar:min()
self:update(0)
end
function progressbar:half()
self:update(math.floor(count/2))
end
function progressbar:getPercent()
return math.floor(((value/count)*100)+.5)
end
function progressbar:getValue()
return value
end
progressbar:update(value)
-- to change colors and modify main components
return progressbar, fill, percentDisplay, fillframe
end
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
-- Addons modify the gui interface directly and do not return anything.
require("gui.addons.extensions")
require("gui.addons.system")
-955
View File
@@ -1,955 +0,0 @@
local multi, thread = require("multi"):init()
local gui = require("gui")
local theme = require("gui.core.theme")
local color = require("gui.core.color")
local TM_THEME = theme:new({
primary = "#124559",
primaryDark = "#01161E",
primaryText = "#AEC3B0"
})
local default_theme = TM_THEME
-- ── layout constants ──────────────────────────────────────────────────────────
-- Columns: Indent+Name, State, Status, Uptime, Priority, Pause, Kill
-- Name shrunk so data columns have room to breathe
local COL_WIDTHS = { 300, 130, 120, 100, 130, 100, 100, 70 }
local COL_KEYS = { "name", "kind", "state", "status", "uptime", "priority", "pause", "kill" }
local COL_LABELS = { "Name", "Type", "State", "Status", "Uptime", "Priority", "", "" }
local SORT_COLS = { "name", "kind", "state", "status", "uptime", "priority" }
local ROW_H = 28
local COL_X = {}
do
local acc = 0
for i, w in ipairs(COL_WIDTHS) do
COL_X[i] = acc
acc = acc + w
end
end
local TOTAL_W = COL_X[#COL_X] + COL_WIDTHS[#COL_WIDTHS] -- 700
-- ── thread state names ────────────────────────────────────────────────────────
local STATE_NAMES = {
[1] = "holding",
[2] = "sleeping",
[3] = "hold+time",
[4] = "skipping",
[5] = "hold+cyc",
[6] = "yielding",
[7] = "running",
}
local function fmtState(obj)
if obj._isPaused then return "paused" end
local t = obj.task
if t == nil then return "running" end
return STATE_NAMES[t] or ("state:"..tostring(t))
end
local PRIORITY_NAMES = {
[1] = "Core",
[4] = "V.High",
[16] = "High",
[64] = "Above",
[256] = "Normal",
[1024] = "Below",
[4096] = "Low",
[16384] = "V.Low",
[65536] = "Idle",
}
local PRIORITY_CYCLE = { 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536 }
local function fmtPriority(obj)
local p = rawget(obj, "Priority") or rawget(obj, "priority")
if not p then return "n/a" end
return PRIORITY_NAMES[p] or tostring(p)
end
local function nextPriority(current)
for i, v in ipairs(PRIORITY_CYCLE) do
if v == current then
return PRIORITY_CYCLE[(i % #PRIORITY_CYCLE) + 1]
end
end
return 256
end
-- ── helpers ───────────────────────────────────────────────────────────────────
local function fmtUptime(secs)
secs = math.floor(secs)
if secs < 60 then return secs .. "s" end
if secs < 3600 then return math.floor(secs/60).."m "..(secs%60).."s" end
return math.floor(secs/3600).."h "..math.floor((secs%3600)/60).."m"
end
local function nowClock() return os.clock() end
-- ── data collection ───────────────────────────────────────────────────────────
-- Returns a flat list of rows with depth so the UI can indent names.
local function collectTasks()
local rows = {}
local stats = multi:getStats()
local function addProc(fullname, proc, depth)
-- Processor header row
rows[#rows+1] = {
isProc = true,
depth = depth,
name = proc.name or fullname,
fullname = fullname,
kind = "processor",
conns = proc.connections or 0,
subs = proc.subscriptions or 0,
}
-- Tasks (Mainloop actors)
local tasks = proc.tasks or {}
for _, task in pairs(tasks) do
if not task.isProcessThread then
rows[#rows+1] = {
isProc = false,
depth = depth + 1,
name = task:getName() or "?",
fullname = fullname,
kind = tostring(task.Type),
state = fmtState(task),
active = not task:isPaused(),
uptime = nowClock() - (task.UPTIME or nowClock()),
priority = rawget(task, "Priority") or 256,
fmtPri = fmtPriority(task),
obj = task,
isThread = false,
}
end
end
-- Threads
local threads = proc.threads or {}
for _, th in pairs(threads) do
rows[#rows+1] = {
isProc = false,
depth = depth + 1,
name = th:getName() or "?",
fullname = fullname,
kind = tostring(th.Type),
state = fmtState(th),
active = not th:isPaused(),
uptime = nowClock() - (th.UPTIME or nowClock()),
priority = rawget(th, "Priority") or 256,
fmtPri = fmtPriority(th),
obj = th,
isThread = true,
}
end
end
-- Root first, then sub-processors sorted
if stats["root"] then
addProc("root", stats["root"], 0)
end
local procNames = {}
for k in pairs(stats) do
if k ~= "root" then procNames[#procNames+1] = k end
end
table.sort(procNames)
for _, k in ipairs(procNames) do
addProc(k, stats[k], 1)
end
return rows
end
function gui:newScrollFrame(x, y, w, h, sx, sy, sw, sh)
local viewport = self:newFrame(x, y, w, h, sx, sy, sw, sh)
viewport.clipDescendants = true
viewport.drawBorder = false
local content = viewport:newFrame(0, 0, w, 0)
content.drawBorder = false
local scrollY = 0
local maxScrollY = 0
local scrollX = 0
local maxScrollX = 0
local SCROLL_SPEED = 40
local SCROLL_BAR_W = 8
local vBar = viewport:newFrame(-SCROLL_BAR_W, 0, SCROLL_BAR_W, 0, 1, 0, 0, 1)
vBar.color = {0.3, 0.3, 0.3}
vBar.drawBorder = false
vBar.visible = false
local vThumb = vBar:newFrame(0, 0, SCROLL_BAR_W, 40)
vThumb.color = {0.6, 0.6, 0.6}
vThumb.drawBorder = false
local hBar = viewport:newFrame(0, -SCROLL_BAR_W, 0, SCROLL_BAR_W, 0, 1, 1)
hBar.color = {0.3, 0.3, 0.3}
hBar.drawBorder = false
hBar.visible = false
local hThumb = hBar:newFrame(0, 0, 40, SCROLL_BAR_W)
hThumb.color = {0.6, 0.6, 0.6}
hThumb.drawBorder = false
local applying = false
local function getViewSize()
local _, _, vw, vh = viewport:getAbsolutes()
return vw, vh
end
local function clamp(val, lo, hi)
return math.max(lo, math.min(hi, val))
end
local function updateScrollbars()
if applying then return end
local vw, vh = getViewSize()
local _, _, cw, ch = content:getAbsolutes()
maxScrollY = math.max(0, ch - vh)
if maxScrollY > 0 then
vBar.visible = true
local thumbH = math.max(20, vh * (vh / ch))
local thumbY = (scrollY / maxScrollY) * (vh - thumbH)
vThumb:setDualDim(0, thumbY, SCROLL_BAR_W, thumbH)
else
vBar.visible = false
scrollY = 0
end
maxScrollX = math.max(0, cw - vw)
if maxScrollX > 0 then
hBar.visible = true
local thumbW = math.max(20, vw * (vw / cw))
local thumbX = (scrollX / maxScrollX) * (vw - thumbW)
hThumb:setDualDim(thumbX, 0, thumbW, SCROLL_BAR_W)
else
hBar.visible = false
scrollX = 0
end
end
local function applyScroll()
if applying then return end
applying = true
scrollY = clamp(scrollY, 0, maxScrollY)
scrollX = clamp(scrollX, 0, maxScrollX)
content:setDualDim(-scrollX, -scrollY)
updateScrollbars()
applying = false
end
viewport.OnWheelMoved(function(x, y)
scrollY = scrollY - y * SCROLL_SPEED
applyScroll()
end)
vThumb:enableDragging(gui.MOUSE_PRIMARY)
vThumb.OnDragging(function(self, dx, dy)
local _, vh = getViewSize()
local _, _, _, thumbH = vThumb:getAbsolutes()
local trackH = vh - thumbH
if trackH <= 0 then return end
scrollY = scrollY + dy * (maxScrollY / trackH)
applyScroll()
end)
hThumb:enableDragging(gui.MOUSE_PRIMARY)
hThumb.OnDragging(function(self, dx, dy)
local vw, _ = getViewSize()
local _, _, thumbW = hThumb:getAbsolutes()
local trackW = vw - thumbW
if trackW <= 0 then return end
scrollX = scrollX + dx * (maxScrollX / trackW)
applyScroll()
end)
content.OnSizeChanged(function()
if applying then return end
local _, _, cw, ch = content:getAbsolutes()
local vw, vh = getViewSize()
maxScrollY = math.max(0, ch - vh)
maxScrollX = math.max(0, cw - vw)
scrollY = clamp(scrollY, 0, maxScrollY)
scrollX = clamp(scrollX, 0, maxScrollX)
updateScrollbars()
end)
viewport.OnSizeChanged(function()
if applying then return end
applyScroll()
end)
function content:scrollTo(sy, sx)
scrollY = sy or scrollY
scrollX = sx or scrollX
applyScroll()
end
function content:scrollBy(dy, dx)
scrollY = scrollY + (dy or 0)
scrollX = scrollX + (dx or 0)
applyScroll()
end
function content:scrollToBottom() scrollY = maxScrollY; applyScroll() end
function content:scrollToTop() scrollY = 0; applyScroll() end
function content:setScrollSpeed(speed) SCROLL_SPEED = speed end
function content:getScrollPos() return scrollX, scrollY end
function content:getMaxScroll() return maxScrollX, maxScrollY end
local _baseSDD = content.setDualDim
function content:setContentSize(cw, ch)
_baseSDD(self, nil, nil, cw or select(3, self:getAbsolutes()), ch)
applyScroll()
end
local _baseDestroy = viewport.destroy
function viewport:destroy()
content:destroy()
_baseDestroy(self)
end
applyScroll()
return content
end
-- ── window constructor (unchanged from original) ──────────────────────────────
local windowCount = 0
function gui:newWindow(x, y, w, h, sx, sy, sw, sh, text, draggable, theme)
local process = gui:newProcessor(text or "window_"..windowCount)
windowCount = windowCount + 1
local parent = self
local pointer = love.mouse.getCursor()
local sizewe = love.mouse.getSystemCursor("sizewe")
local sizens = love.mouse.getSystemCursor("sizens")
local sizenesw = love.mouse.getSystemCursor("sizenesw")
local sizenwse = love.mouse.getSystemCursor("sizenwse")
local theme = theme or default_theme
local header = self:newFrame(x, y, w, 35, sx, sy, sw)
header:setRoundness(10, 10, nil, "top")
local window = header:newFrame(0, 35, 0, h, sx, sy, 1, sh)
window.clipDescendants = true
local left = window:newFrame(0, -4, 4, 0, 0, 0, 0, 1):tag("left")
local right = window:newFrame(-4, -4, 4, 0, 1, 0, 0, 1):tag("right")
local bottom = window:newFrame(4, -4, -8, 4, 0, 1, 1):tag("bottom")
local bottomleft = window:newFrame(0, -4, 4, 4, 0, 1):tag("bleft")
local bottomright = window:newFrame(-4, -4, 4, 4, 1, 1):tag("bright")
gui.apply({
visibility = 0,
I_enableDragging = {gui.MOUSE_PRIMARY},
respectHierarchy = {false},
OnUpdate = function(self) self:topStack() end,
OnDragging = function(self, dx, dy)
local ox, oy, ow, oh = header:getAbsolutes()
local tag = self:getTag()
if tag == "left" or tag == "bleft" then
window:size(0, dy)
header:move(dx, 0)
header:size(-dx, 0)
else
window:size(0, dy)
header:size(dx, 0)
end
local x, y, w, h = header:getAbsolutes()
if w < 200 and (tag == "left" or tag == "bleft") then
header:setDualDim(ox, nil, 200)
elseif w < 200 then
header:setDualDim(nil, nil, 200)
end
local x, y, w, h = window:getAbsolutes()
if h < 100 then window:setDualDim(nil, nil, nil, 100) end
end,
OnDragEnd = function(self) love.mouse.setCursor(pointer) end,
OnEnter = function(self)
local tag = self:getTag()
if tag == "left" or tag == "right" then
love.mouse.setCursor(sizewe)
elseif tag == "bleft" then
love.mouse.setCursor(sizenesw)
elseif tag == "bright" then
love.mouse.setCursor(sizenwse)
else
love.mouse.setCursor(sizens)
end
end,
OnExit = function(self) love.mouse.setCursor(pointer) end,
}, left, right, bottom, bottomleft, bottomright)
local title = header:newTextLabel(text or "", 5, 0, w - 35, 35)
title.clipDescendants = true
title.visibility = 0
title.ignore = true
title:setFont(theme.fontPrimary)
title:fitFont()
function window:setTitle(t) title.text = t end
local X = header:newTextButton("", -25, -25, 20, 20, 1, 1)
X:setRoundness(10, 10)
X:respectHierarchy(false)
X.align = gui.ALIGN_CENTER
X.color = color.red
window.XButton = X
local darkenX = color.darken(color.red, .2)
X.OnEnter(function(self) self.color = darkenX end)
X.OnExit(function(self) self.color = color.red end)
if draggable then
header:enableDragging(gui.MOUSE_PRIMARY)
header:OnDragging(function(self, dx, dy) self:move(dx, dy) end)
header:OnDragEnd(function(self)
local x, y, w, h = self:getAbsolutes()
local width, height = love.graphics.getDimensions()
if x <= 0 then self:setDualDim(0) end
if y <= 0 then self:setDualDim(nil, 0) end
if x + w >= width then self:setDualDim(width - w) end
if y + h >= height then self:setDualDim(nil, height - 35) end
end)
end
window.OnClose = multi:newConnection()
X.OnPressed(function(self, ...)
window.OnClose:Fire(window, ...)
end)
window.OnClose(function()
header:setParent(gui.virtual)
love.mouse.setCursor(pointer)
end)
function window:close() window.OnClose:Fire(self) end
function window:open() header:setParent(parent) end
function window:setTheme(th)
theme = th
title.textColor = theme.colorPrimaryText
header.color = theme.colorPrimaryDark
window.color = theme.colorPrimary
end
function window:getTheme() return theme end
process:newThread(function() window:setTheme(theme) end)
window.OnSizeChanged(function() window:refresh() end)
function window:refresh() window:setTheme(theme) end
window.process = process
window.OnCreated(function(element)
if element:hasType(gui.TYPE_BUTTON) then
element:setFont(theme.fontButton)
element.color = theme.colorButtonNormal
element.textColor = theme.colorButtonText
if not element.__registeredTheme then
element.OnEnter(function(self) self.color = theme.colorButtonHighlight end)
element.OnExit(function(self) self.color = theme.colorButtonNormal end)
end
element:fitFont()
element.__registeredTheme = true
elseif element:hasType(gui.TYPE_TEXT) then
element.color = theme.colorPrimary
element:setFont(theme.fontPrimary)
element.textColor = theme.colorPrimaryText
element:fitFont()
elseif element:hasType(gui.TYPE_FRAME) then
if element.__isHeader then
element.color = theme.colorPrimaryDark
else
element.color = theme.colorPrimary
end
end
end)
return window
end
-- ── row pool ──────────────────────────────────────────────────────────────────
local COLOR_PROC_ROW = TM_THEME.colorPrimaryDark
local COLOR_ROW_EVEN = TM_THEME.colorPrimary
local COLOR_ROW_ODD = TM_THEME.colorPrimaryDark
local COLOR_DEAD = { 0.5, 0.1, 0.1 }
local function makeRowPool(scrollFrame)
local pool = { rows = {}, active = 0 }
local function makeRow(idx)
local yOff = (idx - 1) * ROW_H
local bg = scrollFrame:newFrame(0, yOff, TOTAL_W, ROW_H)
bg.drawBorder = false
-- Name label (col 1) with indent support
local nameLabel = bg:newTextLabel("", COL_X[1] + 4, 0, COL_WIDTHS[1] - 4, ROW_H)
nameLabel.align = gui.ALIGN_LEFT
nameLabel.ignore = true
-- Type, State, Status, Uptime, Priority labels
local kindLbl = bg:newTextLabel("", COL_X[2], 0, COL_WIDTHS[2], ROW_H)
local stateLbl = bg:newTextLabel("", COL_X[3], 0, COL_WIDTHS[3], ROW_H)
local statusLbl = bg:newTextLabel("", COL_X[4], 0, COL_WIDTHS[4], ROW_H)
local uptimeLbl = bg:newTextLabel("", COL_X[5], 0, COL_WIDTHS[5], ROW_H)
local priorityLbl = bg:newTextLabel("", COL_X[6], 0, COL_WIDTHS[6], ROW_H)
for _, lbl in ipairs({kindLbl, stateLbl, statusLbl, uptimeLbl, priorityLbl}) do
lbl.align = gui.ALIGN_CENTER
lbl.ignore = true
end
-- Pause / Resume button
local pauseBtn = bg:newTextButton("", COL_X[7] + 2, 2, COL_WIDTHS[7] - 4, ROW_H - 4)
pauseBtn.align = gui.ALIGN_CENTER
-- Kill button (red)
local killBtn = bg:newTextButton("Kill", COL_X[8] + 2, 2, COL_WIDTHS[8] - 4, ROW_H - 4)
killBtn.align = gui.ALIGN_CENTER
killBtn.color = color.darken(color.red, .1)
local row = {
bg = bg,
nameLabel = nameLabel,
kindLbl = kindLbl,
stateLbl = stateLbl,
statusLbl = statusLbl,
uptimeLbl = uptimeLbl,
priorityLbl = priorityLbl,
pauseBtn = pauseBtn,
killBtn = killBtn,
obj = nil,
isProc = false,
}
pauseBtn.OnReleased(function()
if not row.obj then return end
if row.obj:isPaused() then
row.obj:Resume()
else
row.obj:Pause()
end
statusLbl.text = row.obj:isPaused() and "Paused" or "Running"
pauseBtn.text = row.obj:isPaused() and "Resume" or "Pause"
end)
killBtn.OnReleased(function()
if not row.obj or row.isProc then return end
if row.obj.Kill then
row.obj:Kill()
elseif row.obj.Destroy then
row.obj:Destroy()
end
bg.color = COLOR_DEAD
end)
-- Priority label is clickable to cycle priority
priorityLbl.ignore = false
priorityLbl.OnReleased(function()
if not row.obj or row.isProc then return end
local cur = rawget(row.obj, "Priority") or 256
local next = nextPriority(cur)
if row.obj.setPriority then
row.obj:setPriority(next)
priorityLbl.text = PRIORITY_NAMES[next] or tostring(next)
end
end)
return row
end
function pool:ensure(n)
while #self.rows < n do
self.rows[#self.rows + 1] = makeRow(#self.rows + 1)
end
end
function pool:apply(data)
self:ensure(#data)
self.active = #data
for i, d in ipairs(data) do
local row = self.rows[i]
row.isProc = d.isProc
row.obj = d.isProc and nil or d.obj
-- Row y position
row.bg:setDualDim(nil, (i - 1) * ROW_H)
row.bg.visible = true
if d.isProc then
-- Processor header row
row.bg.color = COLOR_PROC_ROW
row.nameLabel.text = string.rep(" ", d.depth) .. "[" .. d.name .. "]"
row.kindLbl.text = "processor"
row.stateLbl.text = ""
row.statusLbl.text = ""
row.uptimeLbl.text = d.conns .. "c/" .. d.subs .. "s"
row.priorityLbl.text = ""
row.pauseBtn.text = ""
row.pauseBtn.visible = false
row.killBtn.visible = false
else
row.bg.color = (i % 2 == 0) and COLOR_ROW_EVEN or COLOR_ROW_ODD
row.nameLabel.text = string.rep(" ", d.depth) .. d.name
row.kindLbl.text = d.kind or ""
row.stateLbl.text = d.state or ""
row.statusLbl.text = d.active and "Running" or "Paused"
row.uptimeLbl.text = fmtUptime(d.uptime)
row.priorityLbl.text = d.fmtPri or ""
row.pauseBtn.text = d.active and "Pause" or "Resume"
row.pauseBtn.visible = true
row.killBtn.visible = true
end
end
-- Hide unused rows
for i = #data + 1, #self.rows do
self.rows[i].bg.visible = false
self.rows[i].obj = nil
end
scrollFrame:setDualDim(nil, nil, nil, math.max(#data * ROW_H, 1))
end
return pool
end
-- ── error log pool ────────────────────────────────────────────────────────────
local ERROR_ROW_H = 22
local MAX_ERRORS = 200
local function makeErrorPool(scrollFrame)
local pool = { rows = {}, entries = {} }
local function makeRow(idx)
local yOff = (idx - 1) * ERROR_ROW_H
local bg = scrollFrame:newFrame(0, yOff, TOTAL_W, ERROR_ROW_H)
bg.color = (idx % 2 == 0) and COLOR_ROW_EVEN or COLOR_ROW_ODD
bg.drawBorder = false
local lbl = bg:newTextLabel("", 4, 0, TOTAL_W - 4, ERROR_ROW_H)
lbl.align = gui.ALIGN_LEFT
lbl.ignore = true
return { bg = bg, lbl = lbl }
end
function pool:ensure(n)
while #self.rows < n do
self.rows[#self.rows + 1] = makeRow(#self.rows + 1)
end
end
function pool:addEntry(msg, source)
if #self.entries >= MAX_ERRORS then
table.remove(self.entries, 1)
end
local ts = string.format("[%.1fs]", os.clock())
self.entries[#self.entries + 1] = ts .. " [" .. (source or "?") .. "] " .. tostring(msg)
self:refresh()
end
function pool:refresh()
local n = #self.entries
self:ensure(n)
for i, entry in ipairs(self.entries) do
local row = self.rows[i]
row.bg:setDualDim(nil, (i - 1) * ERROR_ROW_H)
row.bg.visible = true
row.lbl.text = entry
end
for i = n + 1, #self.rows do
self.rows[i].bg.visible = false
end
scrollFrame:setDualDim(nil, nil, nil, math.max(n * ERROR_ROW_H, 1))
scrollFrame:scrollToBottom()
end
function pool:clear()
self.entries = {}
self:refresh()
end
return pool
end
-- ── column header row ─────────────────────────────────────────────────────────
local function makeHeader(parent, onSort)
local hdr = parent:newFrame(0, 0, TOTAL_W, ROW_H)
hdr.color = TM_THEME.colorPrimaryDark
hdr.drawBorder = false
local sortCol = nil
local sortAsc = true
local indicators = {}
for i, t in ipairs(COL_LABELS) do
local isSortable = false
for _, k in ipairs(SORT_COLS) do
if k == COL_KEYS[i] then isSortable = true; break end
end
if isSortable then
local btn = hdr:newTextButton(t, COL_X[i], 0, COL_WIDTHS[i], ROW_H)
btn.align = (i == 1) and gui.ALIGN_LEFT or gui.ALIGN_CENTER
indicators[COL_KEYS[i]] = btn
local key = COL_KEYS[i]
btn.OnReleased(function()
if sortCol == key then
sortAsc = not sortAsc
else
sortCol = key
sortAsc = true
end
-- Reset all sortable headers, then mark the active one
for j, label in ipairs(COL_LABELS) do
local b = indicators[COL_KEYS[j]]
if b then
if COL_KEYS[j] == sortCol then
b.text = label .. (sortAsc and "" or "")
else
b.text = label
end
end
end
if onSort then onSort(key, sortAsc) end
end)
else
local lbl = hdr:newTextLabel(t, COL_X[i], 0, COL_WIDTHS[i], ROW_H)
lbl.align = gui.ALIGN_CENTER
lbl.ignore = true
lbl.textColor = TM_THEME.colorPrimaryText
end
end
return hdr
end
-- ── tab bar ───────────────────────────────────────────────────────────────────
local TAB_H = 28
local function makeTabBar(parent, tabs, onSwitch)
local bar = parent:newFrame(0, 0, 0, TAB_H, 0, 0, 1)
bar.color = TM_THEME.colorPrimaryDark
bar.drawBorder = false
local tabW = math.floor(TOTAL_W / #tabs)
local btns = {}
for i, label in ipairs(tabs) do
local btn = bar:newTextButton(label, (i-1)*tabW, 0, tabW, TAB_H)
btn.align = gui.ALIGN_CENTER
btns[i] = btn
btn.OnReleased(function()
onSwitch(i)
end)
end
return bar, btns
end
-- ── sort helper ───────────────────────────────────────────────────────────────
local function sortRows(rows, key, asc)
local function cmp(a, b)
-- Processor rows always float to top within their group; we keep them stable
if a.isProc and b.isProc then return a.fullname < b.fullname end
if a.isProc then return true end
if b.isProc then return false end
local va, vb
if key == "name" then va, vb = a.name or "", b.name or ""
elseif key == "kind" then va, vb = a.kind or "", b.kind or ""
elseif key == "state" then va, vb = a.state or "", b.state or ""
elseif key == "status" then va, vb = (a.active and 0 or 1), (b.active and 0 or 1)
elseif key == "uptime" then va, vb = a.uptime or 0, b.uptime or 0
elseif key == "priority" then va, vb = a.priority or 256, b.priority or 256
else va, vb = tostring(a[key] or ""), tostring(b[key] or "")
end
if asc then return va < vb else return va > vb end
end
-- Stable-ish sort: keep proc header immediately before its children
-- For simplicity we sort the flat list but keep proc rows pinned before
-- the first non-proc row that shares the same fullname.
table.sort(rows, cmp)
end
-- ── public API ────────────────────────────────────────────────────────────────
local taskManager
function gui:showTaskManager()
if taskManager then return end
local WIN_W = TOTAL_W + 20
local WIN_H = 620
taskManager = gui:newWindow(0, 0, WIN_W, WIN_H, nil, nil, nil, nil, "Task Manager", true, TM_THEME)
taskManager.clipDescendants = true
-- ── tab bar ──────────────────────────────────────────────────────────────
local currentTab = 1 -- 1 = tasks, 2 = errors
local taskPanel, errorPanel
local tabBar, tabBtns = makeTabBar(taskManager, {"Tasks", "Errors"}, function(idx)
currentTab = idx
taskPanel.visible = (idx == 1)
errorPanel.visible = (idx == 2)
end)
-- ── tasks panel ──────────────────────────────────────────────────────────
taskPanel = taskManager:newFrame(0, TAB_H, 0, -TAB_H, 0, 0, 1, 1)
taskPanel.drawBorder = false
taskPanel.clipDescendants = true
-- Load bar strip (sits below tab bar, above column headers)
local LOAD_H = 20
local loadStrip = taskPanel:newFrame(0, 0, 0, LOAD_H, 0, 0, 1)
loadStrip.color = TM_THEME.colorPrimaryDark
loadStrip.drawBorder = false
local loadFill = loadStrip:newFrame(0, 2, 1, LOAD_H - 4) -- absolute w=1, no relative anchors
loadFill.color = { 0.1, 0.6, 0.3 }
loadFill.drawBorder = false
-- Label is created AFTER fill so it draws on top of it
local loadLbl = loadStrip:newTextLabel("Load: …", 4, 0, TOTAL_W - 8, LOAD_H)
loadLbl.align = gui.ALIGN_LEFT
loadLbl.ignore = true
local sortKey = nil
local sortAsc = true
local colHdr = makeHeader(taskPanel, function(key, asc)
sortKey = key
sortAsc = asc
end)
colHdr:setDualDim(nil, LOAD_H)
local scrollFrame = taskPanel:newScrollFrame(0, LOAD_H + ROW_H, 0, -(LOAD_H + ROW_H), 0, 0, 1, 1)
local pool = makeRowPool(scrollFrame)
-- ── error panel ──────────────────────────────────────────────────────────
errorPanel = taskManager:newFrame(0, TAB_H, 0, -TAB_H, 0, 0, 1, 1)
errorPanel.drawBorder = false
errorPanel.clipDescendants = true
errorPanel.visible = false
local errHdr = errorPanel:newFrame(0, 0, 0, ROW_H, 0, 0, 1)
errHdr.color = TM_THEME.colorPrimaryDark
errHdr.drawBorder = false
local errTitle = errHdr:newTextLabel("Error Log", 4, 0, 200, ROW_H)
errTitle.align = gui.ALIGN_LEFT
errTitle.ignore = true
local clearBtn = errHdr:newTextButton("Clear", -70, 2, 66, ROW_H - 4, 1)
clearBtn.align = gui.ALIGN_CENTER
local errScroll = errorPanel:newScrollFrame(0, ROW_H, 0, -ROW_H, 0, 0, 1, 1)
local errorPool = makeErrorPool(errScroll)
clearBtn.OnReleased(function() errorPool:clear() end)
-- ── wire up error capture ─────────────────────────────────────────────────
-- Thread errors fire on the *thread's own* OnError, not the processor's.
-- We use OnObjectCreated to hook every thread as it is born, on every
-- processor (including ones created after the task manager opens).
-- We also walk existing threads retroactively for processors already running.
local hookedThreads = {} -- weak set so we don't prevent GC
local function hookThread(th, procName)
if not th.OnError then return end
if hookedThreads[th] then return end
hookedThreads[th] = true
th.OnError(function(self, err,t)
local msg = type(err) == "string" and err or tostring(err or "unknown error")
local name = (th.getName and th:getName()) or "?"
errorPool:addEntry(msg, procName .. "/" .. name)
end)
end
local function hookProc(proc, procName)
-- Hook threads already alive on this processor
local threads = proc.threads or {}
for _, th in ipairs(threads) do
hookThread(th, procName)
end
-- Hook threads created in future on this processor
proc.OnObjectCreated(function(obj)
if obj.Type == multi.registerType("thread", "threads") then
hookThread(obj, procName)
end
end)
end
-- Root process
hookProc(multi, "root")
-- All processors currently registered
for _, proc in ipairs(multi:getProcessors()) do
hookProc(proc, proc:getName())
end
-- Any processors created after this point
multi.OnObjectCreated(function(obj)
if obj.Type == multi.registerType("process", "processes") then
hookProc(obj, obj:getName())
end
end)
-- ── stat line ─────────────────────────────────────────────────────────────
local function setStatLine(n)
taskManager:setTitle("Task Manager — " .. n .. " objects")
end
-- ── background thread: collect tasks ──────────────────────────────────────
local function isOpen()
return not taskManager:isDescendantOf(gui.virtual)
end
local pendingData = nil
local dirty = false
taskManager.process:newThread("TM_collect", function()
while true do
thread.hold(isOpen)
local data = collectTasks()
pendingData = data
dirty = true
thread.sleep(1)
end
end)
-- ── load probe ────────────────────────────────────────────────────────────
-- Install once. getLoad() is now non-blocking — just reads the EMA state.
local schedulerProbe = require("gui.core.probe")
schedulerProbe:install(multi)
-- ── main-thread update ────────────────────────────────────────────────────
taskManager.OnUpdate(function()
taskManager:topStack()
-- Apply task data
if dirty and pendingData then
dirty = false
local data = pendingData
pendingData = nil
if sortKey then
sortRows(data, sortKey, sortAsc)
end
pool:apply(data)
setStatLine(#data)
end
-- Load bar — getLoad() is now non-blocking, safe to call every frame
local pct, lagMs = multi:getLoad()
local _, _, barW, _ = loadStrip:getAbsolutes()
local fillW = math.max(1, math.floor(barW * pct / 100))
loadFill:setDualDim(nil, nil, fillW)
if pct < 50 then
loadFill.color = { 0.1, 0.6, 0.3 }
elseif pct < 80 then
loadFill.color = { 0.8, 0.6, 0.1 }
else
loadFill.color = { 0.8, 0.15, 0.1 }
end
loadLbl.text = string.format("Load: %d%% Lag: %.1fms", pct, lagMs)
end)
end
-- ── hotkey ────────────────────────────────────────────────────────────────────
ToggleTaskManager = gui:setHotKey({"lctrl","t"}) +
gui:setHotKey({"rctrl","t"})
ToggleTaskManager(function()
if not taskManager then
gui:showTaskManager()
elseif taskManager:isActive() then
taskManager:close()
else
taskManager:open()
end
end)
ToggleTaskManager:Fire()
taskManager:close()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

-1
View File
@@ -1 +0,0 @@
#
-26
View File
@@ -1,26 +0,0 @@
local gui = require("gui")
function newCanvas(domain)
local c
if domain == "visual" then
c = gui:newVisualFrame()
else
c = gui:newVirtualFrame()
end
c:fullFrame()
function c:swap(c1, c2)
local temp = c1.children
c1.children = c2.children
c2.children = temp
for i,v in pairs(c1.children) do
v.parent = c1
end
for i,v in pairs(c2.children) do
v.parent = c2
end
end
return c
end
return newCanvas
-1660
View File
File diff suppressed because it is too large Load Diff
-437
View File
@@ -1,437 +0,0 @@
-- GIF Loader for Love2D with LZW Decompression
-- Note: love.data.compress/decompress don't support LZW, so we implement it
local GifLoader = {}
-- Pure Lua LZW decompression for GIF
local function decompressLZW(data, minCodeSize)
local clearCode = 2 ^ minCodeSize
local endCode = clearCode + 1
local nextCode = endCode + 1
local codeSize = minCodeSize + 1
local dict = {}
for i = 0, clearCode - 1 do
dict[i] = {string.byte(string.char(i))}
end
local output = {}
local bits = 0
local bitBuffer = 0
local pos = 1
local prevCode = nil
local function readCode()
while bits < codeSize do
if pos > #data then return nil end
bitBuffer = bitBuffer + bit.lshift(string.byte(data, pos), bits)
bits = bits + 8
pos = pos + 1
end
local code = bit.band(bitBuffer, bit.lshift(1, codeSize) - 1)
bitBuffer = bit.rshift(bitBuffer, codeSize)
bits = bits - codeSize
return code
end
local first = true
while true do
local code = readCode()
if not code or code == endCode then break end
if code == clearCode then
dict = {}
for i = 0, clearCode - 1 do
dict[i] = {string.byte(string.char(i))}
end
nextCode = endCode + 1
codeSize = minCodeSize + 1
prevCode = nil
first = true
else
local entry
if dict[code] then
entry = dict[code]
elseif code == nextCode and prevCode then
-- Special case: code not in dict yet
entry = {}
for i = 1, #dict[prevCode] do
entry[i] = dict[prevCode][i]
end
entry[#entry + 1] = dict[prevCode][1]
else
-- Invalid code, stop
break
end
-- Output the entry
for i = 1, #entry do
table.insert(output, entry[i])
end
-- Add new entry to dictionary
if not first and prevCode and nextCode < 4096 then
local newEntry = {}
for i = 1, #dict[prevCode] do
newEntry[i] = dict[prevCode][i]
end
newEntry[#newEntry + 1] = entry[1]
dict[nextCode] = newEntry
nextCode = nextCode + 1
-- Increase code size when needed
if nextCode >= bit.lshift(1, codeSize) and codeSize < 12 then
codeSize = codeSize + 1
end
end
prevCode = code
first = false
end
end
-- Convert output bytes to string
local result = {}
for i = 1, #output do
result[i] = string.char(output[i])
end
return table.concat(result)
end
function GifLoader.load(filepath)
local fileData = love.filesystem.read(filepath)
if not fileData then
error("Could not read GIF file: " .. filepath)
end
local gif = {
frames = {},
frameData = {}, -- Store ImageData for frame composition
delays = {},
currentFrame = 1,
timer = 0,
width = 0,
height = 0,
playing = true,
loop = true,
getWidth = function(self)
return self.width
end,
getHeight = function(self)
return self.height
end,
}
-- Parse GIF header
local header = fileData:sub(1, 6)
if header ~= "GIF87a" and header ~= "GIF89a" then
error("Not a valid GIF file")
end
-- Read logical screen descriptor
local pos = 7
gif.width = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256
gif.height = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256
local packed = string.byte(fileData, pos + 4)
local hasGlobalColorTable = bit.band(packed, 0x80) ~= 0
local backgroundColorIndex = string.byte(fileData, pos + 5)
pos = pos + 7
-- Read global color table
local globalColorTable = {}
if hasGlobalColorTable then
local size = 2 ^ (bit.band(packed, 0x07) + 1)
for i = 1, size do
local r = string.byte(fileData, pos) / 255
local g = string.byte(fileData, pos + 1) / 255
local b = string.byte(fileData, pos + 2) / 255
table.insert(globalColorTable, {r, g, b, 1})
pos = pos + 3
end
end
-- Parse blocks
local delay = 0.1
local transparentIndex = nil
local disposalMethod = 0
local delayForNextFrame = 0.1 -- Track delay for next frame
while pos <= #fileData do
local separator = string.byte(fileData, pos)
if separator == 0x21 then -- Extension
local label = string.byte(fileData, pos + 1)
pos = pos + 2
if label == 0xF9 then -- Graphic Control Extension
local blockSize = string.byte(fileData, pos)
pos = pos + 1
local flags = string.byte(fileData, pos)
disposalMethod = bit.rshift(bit.band(flags, 0x1C), 2)
local hasTransparency = bit.band(flags, 0x01) ~= 0
local delayTime = string.byte(fileData, pos + 1) + string.byte(fileData, pos + 2) * 256
-- GIF delay is in hundredths of a second, convert to seconds
-- Many GIFs use 0 or very small delays, set a minimum
if delayTime == 0 then
delayForNextFrame = 0.1 -- Default 100ms
elseif delayTime <= 2 then
delayForNextFrame = 0.02 -- Minimum 20ms for very fast animations
else
delayForNextFrame = delayTime / 100
end
if hasTransparency then
transparentIndex = string.byte(fileData, pos + 3)
else
transparentIndex = nil
end
pos = pos + blockSize + 1
else
-- Skip other extensions
repeat
local blockSize = string.byte(fileData, pos)
pos = pos + 1
if blockSize > 0 then
pos = pos + blockSize
end
until blockSize == 0
end
elseif separator == 0x2C then -- Image Descriptor
pos = pos + 1
local left = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256
local top = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256
local width = string.byte(fileData, pos + 4) + string.byte(fileData, pos + 5) * 256
local height = string.byte(fileData, pos + 6) + string.byte(fileData, pos + 7) * 256
local imgPacked = string.byte(fileData, pos + 8)
local hasLocalColorTable = bit.band(imgPacked, 0x80) ~= 0
local interlaced = bit.band(imgPacked, 0x40) ~= 0
pos = pos + 9
local colorTable = globalColorTable
if hasLocalColorTable then
local size = 2 ^ (bit.band(imgPacked, 0x07) + 1)
colorTable = {}
for i = 1, size do
local r = string.byte(fileData, pos) / 255
local g = string.byte(fileData, pos + 1) / 255
local b = string.byte(fileData, pos + 2) / 255
table.insert(colorTable, {r, g, b, 1})
pos = pos + 3
end
end
-- Read LZW minimum code size
local minCodeSize = string.byte(fileData, pos)
pos = pos + 1
-- Read compressed image data blocks
local compressedData = {}
while true do
local blockSize = string.byte(fileData, pos)
pos = pos + 1
if blockSize == 0 then break end
table.insert(compressedData, fileData:sub(pos, pos + blockSize - 1))
pos = pos + blockSize
end
-- Decompress image data
local indexStream = decompressLZW(table.concat(compressedData), minCodeSize)
-- Create image data
local imageData = love.image.newImageData(gif.width, gif.height)
-- Fill with background if first frame
if #gif.frames == 0 and backgroundColorIndex and globalColorTable[backgroundColorIndex + 1] then
local bg = globalColorTable[backgroundColorIndex + 1]
for y = 0, gif.height - 1 do
for x = 0, gif.width - 1 do
imageData:setPixel(x, y, bg[1], bg[2], bg[3], bg[4])
end
end
elseif #gif.frames > 0 then
-- Copy previous frame if needed
local prevData = gif.frameData[#gif.frameData]
imageData:paste(prevData, 0, 0, 0, 0, gif.width, gif.height)
end
-- Draw current frame
if indexStream and #indexStream > 0 then
local idx = 1
-- Use mapPixel for faster pixel operations
local function setPixels(x, y, r, g, b, a)
if x >= left and x < left + width and y >= top and y < top + height then
local pixelIdx = (y - top) * width + (x - left) + 1
if pixelIdx <= #indexStream then
local colorIndex = string.byte(indexStream, pixelIdx)
-- Skip transparent pixels
if transparentIndex == nil or colorIndex ~= transparentIndex then
if colorTable[colorIndex + 1] then
local color = colorTable[colorIndex + 1]
return color[1], color[2], color[3], color[4]
end
end
end
end
return r, g, b, a
end
-- Only update the region where the frame is located
for y = top, top + height - 1 do
for x = left, left + width - 1 do
local pixelIdx = (y - top) * width + (x - left) + 1
if pixelIdx <= #indexStream then
local colorIndex = string.byte(indexStream, pixelIdx)
-- Skip transparent pixels
if transparentIndex == nil or colorIndex ~= transparentIndex then
if colorTable[colorIndex + 1] then
local color = colorTable[colorIndex + 1]
imageData:setPixel(x, y, color[1], color[2], color[3], color[4])
end
end
end
end
end
end
table.insert(gif.frameData, imageData)
table.insert(gif.frames, love.graphics.newImage(imageData))
table.insert(gif.delays, delayForNextFrame)
-- Reset delay for next frame
delayForNextFrame = 0.1
elseif separator == 0x3B then -- Trailer
break
else
pos = pos + 1
end
end
if #gif.frames == 0 then
error("No frames found in GIF")
end
-- Ensure all frames have valid delays
for i = 1, #gif.delays do
if gif.delays[i] <= 0 or gif.delays[i] ~= gif.delays[i] then -- check for 0 or NaN
gif.delays[i] = 0.1
end
end
return gif
end
function GifLoader.Updater(gif, proc)
local wait = function()
return gif.playing or gif.kill
end
proc:newThread("Gif Handler",function()
while true do
-- Only run if not paused
if gif.kill then -- When we want to clean up
thread.kill()
end
thread.hold(wait)
thread.sleep(gif.delays[gif.currentFrame] * 4)
gif.currentFrame = gif.currentFrame + 1
if gif.currentFrame > #gif.frames then
if gif.loop then
gif.currentFrame = 1
else
gif.currentFrame = #gif.frames
gif.playing = false
gif.timer = 0
end
end
end
end)
end
function GifLoader.update(gif, dt)
if not gif.playing or #gif.frames <= 1 then return end
gif.timer = gif.timer + dt
-- Simple, accurate frame advancement
if gif.timer >= gif.delays[gif.currentFrame] then
-- Subtract the current frame's delay
gif.timer = gif.timer - gif.delays[gif.currentFrame]
-- Move to next frame
gif.currentFrame = gif.currentFrame + 1
if gif.currentFrame > #gif.frames then
if gif.loop then
gif.currentFrame = 1
else
gif.currentFrame = #gif.frames
gif.playing = false
gif.timer = 0
end
end
-- If we've accumulated too much time (lag spike), cap it
if gif.timer > 0.5 then
gif.timer = 0
end
end
end
function GifLoader.draw(gif, x, y, r, sx, sy, ox, oy)
if gif.frames[gif.currentFrame] then
love.graphics.draw(gif.frames[gif.currentFrame], x, y, r or 0, sx or 1, sy or 1, ox or 0, oy or 0)
end
end
function GifLoader.play(gif)
gif.playing = true
end
function GifLoader.pause(gif)
gif.playing = false
end
function GifLoader.reset(gif)
gif.currentFrame = 1
gif.timer = 0
end
function GifLoader.setFixedFramerate(gif, fps)
local delay = 1 / fps
for i = 1, #gif.delays do
gif.delays[i] = delay
end
end
function GifLoader.getInfo(gif)
return {
width = gif.width,
height = gif.height,
frameCount = #gif.frames,
delays = gif.delays, -- Show actual delays for debugging
totalDuration = (function()
local total = 0
for i = 1, #gif.delays do
total = total + gif.delays[i]
end
return total
end)()
}
end
return GifLoader
-115
View File
@@ -1,115 +0,0 @@
--[[
scheduler_probe.lua
-------------------
A drop-in replacement for multi:getLoad() based on scheduler tick-slip
rather than step-count benchmarking.
THEORY
------
Schedule a repeating timer at a fixed interval T. Each time it fires,
measure how much later than T it actually arrived. On an idle scheduler
the slip is near zero. Under load the main loop is busy with other tasks
between iterations, so ticks are delayed.
We express load as:
lag = actual_interval - target_interval (seconds)
lag_ratio = lag / target_interval (0 = perfect, 1 = 1 full interval late)
load% = clamp(lag_ratio * 100, 0, 100)
To smooth out single-frame spikes we keep an exponential moving average
(EMA) of lag_ratio with a configurable smoothing factor.
WHY THIS IS BETTER THAN THE STEP-COUNT APPROACH
------------------------------------------------
- No calibration baseline that drifts with object count or warm-up
- No magic exponents or divisors
- Does not block or create temporary objects on each call
- Measures actual scheduler responsiveness, not raw throughput
- Works correctly regardless of which processor calls it
- A single lightweight TLoop is the only permanent overhead
USAGE
-----
local probe = require("scheduler_probe")
probe:install(multi) -- once, at startup
-- anywhere, non-blocking:
local load, lagMs = multi:getLoad()
-- load : integer 0-100
-- lagMs : smoothed lag in milliseconds (useful for display)
OPTIONAL PARAMETERS
-------------------
probe:install(multi, {
interval = 0.05, -- probe fires every N seconds (default 0.05 = 50ms)
alpha = 0.15, -- EMA smoothing factor 0-1 (default 0.15)
-- lower = smoother but slower to react
-- higher = more reactive but noisier
maxLag = 0.5, -- lag value (seconds) that maps to 100% load (default 0.5)
-- tune this to match your target frame budget
})
]]
local probe = {}
-- EMA state — written by the TLoop callback, read by getLoad()
-- Both are plain numbers so Lua's assignment is atomic within one thread.
local _emaRatio = 0 -- smoothed lag / maxLag, clamped 0-1
local _lagMs = 0 -- smoothed lag in milliseconds for display
local _installed = false
function probe:install(multi_obj, opts)
if _installed then return end
_installed = true
opts = opts or {}
local INTERVAL = opts.interval or 0.05 -- seconds between probes
local ALPHA = opts.alpha or 0.15 -- EMA weight for new sample
local MAX_LAG = opts.maxLag or 0.5 -- seconds of lag = 100% load
local clock = os.clock
-- Track when the tick *should* have fired so we can compute slip
-- relative to the scheduled time, not relative to the previous firing.
-- This avoids error accumulation over long runs.
local expectedTime = clock() + INTERVAL
local tloop = multi_obj:newTLoop(nil, INTERVAL)
tloop:setName("SchedulerProbe")
tloop:setPriority("core") -- run as early as possible each frame
tloop.OnLoop(function(self, life, dt)
local now = clock()
local lag = math.max(0, now - expectedTime) -- never negative
local ratio = math.min(lag / MAX_LAG, 1) -- clamp to [0,1]
-- Exponential moving average: new = alpha*sample + (1-alpha)*old
_emaRatio = ALPHA * ratio + (1 - ALPHA) * _emaRatio
_lagMs = ALPHA * lag*1000 + (1 - ALPHA) * _lagMs
-- Advance expected time by one interval from where it *should* have been,
-- not from now — prevents the probe from drifting under sustained load.
expectedTime = expectedTime + INTERVAL
-- If we fall more than one interval behind (e.g. after a long GC pause),
-- re-anchor so we don't fire in a catch-up burst.
if now > expectedTime + INTERVAL then
expectedTime = now + INTERVAL
end
end)
-- Replace multi:getLoad() with a non-blocking version that just reads the EMA
function multi_obj:getLoad()
local pct = math.ceil(_emaRatio * 100)
return pct, _lagMs
end
-- Also expose raw probe state for diagnostics
function multi_obj:getSchedulerLag()
return _lagMs, _emaRatio
end
return tloop
end
return probe
-91
View File
@@ -1,91 +0,0 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local transition = require("gui.core.transitions")
-- Triggers press then release
local function getPosition(obj, x, y)
if not x or y then
local cx, cy, w, h = obj:getAbsolutes()
return cx + w/2, cy + h/2
else
return x, y
end
end
proc = gui:getProcessor()
local simulate = {}
function simulate:Press(button, x, y, istouch)
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
love.mouse.setPosition(x, y)
gui.Events.OnMousePressed:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
end
function simulate:Release(button, x, y, istouch)
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
love.mouse.setPosition(x, y)
gui.Events.OnMouseReleased:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
end
simulate.Click = proc:newFunction(function(self, button, x, y, istouch)
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
love.mouse.setPosition(x, y)
gui.Events.OnMousePressed:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
thread.skip(1)
gui.Events.OnMouseReleased:Fire(x, y, button or gui.MOUSE_PRIMARY, istouch or false)
end, true)
simulate.Move = proc:newFunction(function(self, dx, dy, x, y, istouch)
local dx, dy = dx or 0, dy or 0
if self then
x, y = getPosition(self, x, y)
elseif x == nil or y == nil then
x, y = love.mouse.getPosition()
end
if dx == 0 and dy == 0 then
_x, _y = love.mouse.getPosition()
if x == _x and y == _y then
return
end
local dx, dy = 0, 0
dx = x - _x
dy = y - _y
return simulate.Move(nil, dx, dy)
end
gui.Events.OnMouseMoved:Fire(x, y, 0, 0, istouch or false)
thread.skip(1)
local gx = transition.glide(0, dx, .25)
local gy = transition.glide(0, dy, .25)
local xx = gx()
xx.OnStep(function(p)
_x, _y = love.mouse.getPosition()
love.mouse.setPosition(x + p, _y)
end)
local yy = gy()
yy.OnStep(function(p)
_x, _y = love.mouse.getPosition()
love.mouse.setPosition(_x, y + p)
end)
local event = xx.OnStop * yy.OnStop
if not(dx==0 and dy == 0) then
thread.hold(event)
end
gui.Events.OnMouseMoved:Fire(x + dx, y + dy, 0, 0, istouch or false)
end, true)
return simulate
-135
View File
@@ -1,135 +0,0 @@
local color = require("gui.core.color")
local theme = {}
local defaultFont = love.graphics.getFont()
theme.__index = theme
local function generate_harmonious_colors(num_colors, lightness)
local base_hue = math.random(0, 360) -- random starting hue
local colors = {}
for i = 1, num_colors do
local new_hue = (base_hue + (360 / num_colors) * i) % 360 -- offset hue by 1/n of the color wheel
if lightness == "dark" then
table.insert(colors, color.new(color.hsl(new_hue, math.random(45, 55), math.random(30, 40))))
elseif lightness == "light" then
table.insert(colors, color.new(color.hsl(new_hue, math.random(45, 55), math.random(60, 80))))
else
table.insert(colors, color.new(color.hsl(new_hue, math.random(45, 55), math.random(30, 80))))
end
end
return colors
end
function theme:random(seed, lightness, rand)
local seed = seed or math.random(0,9999999999)
math.randomseed(seed)
local harmonious_colors = generate_harmonious_colors(3, lightness)
local t = theme:new(unpack(harmonious_colors))
if lightness == "dark" then
t.colorPrimaryText = color.lighten(t.colorPrimaryText, .8)
t.colorButtonText = color.lighten(t.colorButtonText, .7)
elseif lightness == "light" then
t.colorPrimaryText = color.darken(t.colorPrimaryText, .8)
t.colorButtonText = color.darken(t.colorButtonText, .7)
else
if color.getAverageLightness(t.colorPrimary)<.5 then
t.colorPrimaryText = color.lighten(t.colorPrimaryText, .5)
t.colorButtonNormal = color.lighten(t.colorButtonNormal, .2)
else
t.colorPrimaryText = color.darken(t.colorPrimaryText, .3)
end
if color.getAverageLightness(t.colorPrimary)<.5 then
t.colorButtonText = color.lighten(t.colorButtonText, .5)
else
t.colorButtonText = color.darken(t.colorButtonText, .3)
end
end
t.seed = seed
return t
end
function theme:dump()
return '"' .. table.concat({color.rgbToHex(self.colorPrimary), color.rgbToHex(self.colorPrimaryText), color.rgbToHex(self.colorButtonText)},"\",\"") .. '"'
end
local function newColor(c,default)
if not c then
return default
end
return color.new(c)
end
function theme:new(colorPrimary, primaryText, buttonText, buttonNormal, primaryTextFont, buttonTextFont)
local c = {}
setmetatable(c, theme)
if type(colorPrimary) == "table" then
local opts = colorPrimary
c.colorPrimary = newColor(opts.primary)
c.colorPrimaryDark = newColor(opts.primaryDark, color.darken(c.colorPrimary,.4))
c.colorPrimaryText = newColor(opts.primaryText)
c.colorButtonNormal = newColor(opts.buttonNormal, color.darken(c.colorPrimary,.2))
c.colorButtonHighlight = newColor(opts.buttonHighlight, color.lighten(c.colorPrimary,.2))
c.colorButtonText = newColor(opts.buttonText, c.colorPrimaryText)
c.fontPrimary = opts.textFont or defaultFont
c.fontButton = opts.buttonTextFont or defaultFont
for i,v in pairs(colorPrimary) do
if not c[i] then
c[i] = v -- only overwrite non managed fields
end
end
return c
end
c.colorPrimary = color.new(colorPrimary)
c.colorPrimaryDark = color.darken(c.colorPrimary,.4)
c.colorPrimaryText = color.new(primaryText)
c.colorButtonNormal = newColor(buttonNormal) or color.darken(c.colorPrimary,.2)
c.colorButtonHighlight = color.lighten(c.colorButtonNormal,.2)
c.colorButtonText = color.new(buttonText)
c.fontPrimary = primaryTextFont or defaultFont
c.fontButton = buttonTextFont or defaultFont
return c
end
function theme:setColorPrimary(c)
self.colorPrimary = color.new(c)
end
function theme:setColorPrimaryDark(c)
self.colorPrimaryDark = color.new(c)
end
function theme:setColorPrimaryText(c)
self.colorPrimaryText = color.new(c)
end
function theme:setColorButtonNormal(c)
self.colorButtonNormal = color.new(c)
end
function theme:setColorButtonHighlight(c)
self.colorButtonHighlight = color.new(c)
end
function theme:setColorButtonText(c)
self.colorButtonText = color.new(c)
end
function theme:setFontPrimary(c)
self.fontPrimary = c
end
function theme:setFontButton(c)
self.fontButton = c
end
function theme:getSeed()
return self.seed
end
return theme
-93
View File
@@ -1,93 +0,0 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local processor = gui:newProcessor("Transition Processor")
local transition = {}
local width, height, flags = love.window.getMode()
local fps = 60
if flags.refreshrate > 0 then
fps = flags.refreshrate
end
transition.__index = transition
transition.__call = function(t, start, stop, time, ...)
local args = {...}
return function(st, sp, ti)
local s = st or start
local e = sp or stop
local dur = ti or time or 1
if not s or not e then return multi.error("start and stop must be supplied") end
if s == e then
local temp = {
OnStep = function() end,
OnStop = multi:newConnection()
}
processor:newTask(function()
temp.OnStop:Fire()
end)
return temp
end
local handle = t.func(t, s, e, dur, unpack(args))
return {
OnStep = handle.OnStatus,
OnStop = handle.OnReturn + handle.OnError,
Kill = function() t:Kill() end
}
end
end
function transition:newTransition(func)
local c = {}
setmetatable(c, self)
c.fps = fps
c.func = processor:newFunction(func)
c.OnStop = multi:newConnection()
c.kill = false
function c:SetFPS(f)
self.fps = f
end
function c:GetFPS()
return self.fps
end
function c:Kill()
if c.running then
c.kill = true
end
end
return c
end
transition.glide = transition:newTransition(function(t, start, stop, time)
local split = stop - start
local startTime = love.timer.getTime()
local endTime = startTime + time
local stepTime = 1 / t.fps
t.running = true
while not t.kill do
thread.sleep(stepTime)
local now = love.timer.getTime()
local elapsed = now - startTime
local clamped = math.min(elapsed, time)
local value = start + (clamped / time) * split
thread.pushStatus(value, clamped)
if now >= endTime then
break
end
end
t.running = false
t.kill = false
end)
return transition
File diff suppressed because it is too large Load Diff
-186
View File
@@ -1,186 +0,0 @@
# ── Full GUI YAML reference ─────────────────────────────────────────
# Every element shares these fields:
type: frame # frame | virtual-frame | visual-frame |
# label | button | textbox |
# image | image-button | video
# ── Position & size (dual-dimension system) ─────────────────────────
# Offset (pixels):
x: 10
y: 10
w: 200
h: 50
# Or as lists:
pos: [10, 10]
size: [200, 50]
# Scale (0.01.0 of parent):
sx: 0.0 # scale pos x
sy: 0.0 # scale pos y
sw: 0.5 # scale size x — 50% of parent width
sh: 1.0 # scale size y — 100% of parent height
# Or as lists:
scale-pos: [0.0, 0.0]
scale-size: [0.5, 1.0]
# Shorthand for "fill parent completely":
full-frame: true
# ── Appearance ───────────────────────────────────────────────────────
color: "#3a7bd5" # hex string
color: [58, 123, 213] # RGB 0-255
color: [0.23, 0.48, 0.84] # RGB 0-1
border-color: "#000000"
draw-border: true
visible: true
active: true
visibility: 1.0 # 0.01.0 alpha for the background rect
rotation: 45 # degrees
# ── Form factor ──────────────────────────────────────────────────────
form: rectangle # default — no extra fields needed
form: circle
radius: 40 # optional; derived from w if omitted
segments: 32
form: arc
radius: 60
arc-type: open # open | closed | pie
angle-start: 0
angle-end: 3.14159
segments: 32
# ── Roundness ────────────────────────────────────────────────────────
roundness: 8 # uniform rx/ry
roundness: [8, 8, 30] # rx, ry, segments
roundness: top # "top" or "bottom" special mode
roundness:
side: top # full table form
# ── Tags ─────────────────────────────────────────────────────────────
tag: "my-element" # single fast-lookup tag (gui:tag)
tags: # multi-tag (gui:setTag)
- draggable-panel
- visual
# ── Stack order ──────────────────────────────────────────────────────
stack: top # bring to front
stack: bottom # send to back
# ── Centering ────────────────────────────────────────────────────────
center-x: true # horizontally center within parent
center-y: true # vertically center within parent
# ── Dragging ─────────────────────────────────────────────────────────
draggable: 1 # mouse button (1=primary, 2=secondary, 3=middle)
# false/omit to disable
# ── Hierarchy & clipping ─────────────────────────────────────────────
respect-hierarchy: true # blocks presses when covered by sibling
clip-descendants: true # scissor-clips all children to this rect
# ── Square locking ───────────────────────────────────────────────────
square: w # force height = width
square: h # force width = height
# ── Text element fields (label, button, textbox) ─────────────────────
text: "Hello, world!"
align: left # left | center | right
text-color: "#ffffff"
text-visibility: 1.0
text-scale: [1.0, 1.0] # [scaleX, scaleY]
text-offset: [0, 0] # [offsetX, offsetY]
text-shear: [0, 0] # [shearX, shearY]
font: 16 # size, uses default font
font: "fonts/Roboto-Regular.ttf" # path, uses font-size below
font-size: 18
font:
file: "fonts/Roboto-Regular.ttf"
size: 18
fit-font: true # auto-fit font to element bounds
fit-font:
min: 8
max: 200
scale: 0.95 # shrink slightly from computed best
center-font: true # vertically center glyphs in box
center-font: 10 # with y_offset
# ── Image element fields (image, image-button) ───────────────────────
source: "assets/logo.png"
tile: [0, 0, 64, 64] # sub-quad [x, y, w, h]
scale-x: 1.0
scale-y: 1.0
flip: horizontal # horizontal | vertical | both
image-color: "#ffffff"
image-visibility: 1.0
# Gradient (replaces solid image with a generated gradient image):
gradient:
direction: vertical # vertical | horizontal
colors:
- [255, 80, 80, 255]
- [80, 80, 255, 255]
# ── Video element fields ──────────────────────────────────────────────
source: "assets/intro.ogv"
volume: 0.8
autoplay: true
video-color: "#ffffff"
video-visibility: 1.0
# ── Events ───────────────────────────────────────────────────────────
# Value can be a global function name (string) or inline Lua source.
on-pressed: "myPressHandler"
on-released: "myReleaseHandler"
on-released-outer: "myOuterRelease"
on-pressed-outer: "myOuterPress"
on-enter: "onHoverEnter"
on-exit: "onHoverExit"
on-moved: "onMouseMoved"
on-drag-start: "onDragStart"
on-dragging: "onDragging"
on-drag-end: "onDragEnd"
on-wheel: "onWheel"
on-size-changed: "onResized"
on-position-changed: "onMoved"
on-destroy: "onDestroy"
on-load: "onLoaded"
on-return: "onSubmit" # textbox only
on-update: "onUpdate" # called every frame
# Inline Lua (multi-line string):
on-pressed: |
print("pressed!", self.text)
# Per-element hotkeys:
hotkeys:
- keys: [lctrl, s]
action: "saveDocument"
- keys: [escape]
action: "closeDialog"
# ── Children ─────────────────────────────────────────────────────────
children:
- type: label
text: "I am a child"
x: 10
y: 10
w: 180
h: 30
color: "#2a2a2a"
text-color: "#ffffff"
align: center
- type: button
text: "Click me"
x: 10
y: 50
w: 100
h: 36
on-pressed: "handleClick"
children: [] # buttons can also have children
-2447
View File
File diff suppressed because it is too large Load Diff
-70
View File
@@ -1,70 +0,0 @@
local blur_h = love.graphics.newShader([[
extern vec2 size;
extern float radius;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 step = vec2(1.0 / size.x, 0.0);
vec4 result = vec4(0.0);
float total = 0.0;
float sigma = radius * 0.5;
float r = floor(radius);
float i = -r;
while (i <= r) {
float weight = exp(-0.5 * (i * i) / (sigma * sigma));
result += Texel(tex, tc + step * i) * weight;
total += weight;
i = i + 1.0;
}
return (result / total) * color;
}
]])
local blur_v = love.graphics.newShader([[
extern vec2 size;
extern float radius;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 step = vec2(0.0, 1.0 / size.y);
vec4 result = vec4(0.0);
float total = 0.0;
float sigma = radius * 0.5;
float r = floor(radius);
float i = -r;
while (i <= r) {
float weight = exp(-0.5 * (i * i) / (sigma * sigma));
result += Texel(tex, tc + step * i) * weight;
total += weight;
i = i + 1.0;
}
return (result / total) * color;
}
]])
local c = {}
function c:setBlur(radius)
radius = radius or 8
c.__blur = {
radius = radius,
canvas1 = nil, -- populated on first draw
canvas2 = nil,
shader_h = blur_h,
shader_v = blur_v,
}
end
function c:clearBlur()
c.__blur = nil
end
function c:setBlurRadius(radius)
if c.__blur then
c.__blur.radius = radius
end
end
return {init = function(gui) gui.registerExtension(c) end}
-274
View File
@@ -1,274 +0,0 @@
local shaders = {}
function NewShader(name, shader, opt_args)
local uniforms = {}
local opt_args = opt_args or {}
for typ, uname in shader:gmatch("extern%s+(%w+)%s+(%w+)%s*;") do
if uname ~= "time" and uname ~= "size" then
table.insert(uniforms, "Argument \"" .. uname .. "\" is expected to be: \"" .. typ .. "\"")
opt_args[uname] = true
end
end
if #opt_args > 0 or #uniforms > 0 then
opt_args.source = love.graphics.newShader(shader)
shaders[name] = opt_args
if opt_args.usage == nil then
opt_args.usage = function() return table.concat(uniforms,"\n").."\n" end
end
else
shaders[name] = love.graphics.newShader(shader)
end
end
function GetShaderUniforms(name)
local source = shaders[name] -- we need the source not the compiled shader
local uniforms = {}
for type, uname in source:gmatch("extern%s+(%w+)%s+(%w+)%s*;") do
uniforms[uname] = type
end
return uniforms
end
-- ─────────────────────────────────────────────
-- GLOW (original kept for reference)
-- Uniforms: vec2 size, float time
-- ─────────────────────────────────────────────
NewShader("glow", [[
extern float time;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc);
float pulse = 0.15 * sin(time * 3.0) + 0.85;
return vec4(pixel.rgb * pulse, pixel.a) * color;
}
]])
-- ─────────────────────────────────────────────
-- GRAYSCALE
-- Converts the sprite to grayscale (good for disabled/inactive state).
-- Uniforms: float grayScale (0.0 = full color, 1.0 = full gray)
-- ─────────────────────────────────────────────
NewShader("grayscale", [[
extern float amount;
vec4 effect(vec4 col, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * col;
float gray = dot(pixel.rgb, vec3(0.299, 0.587, 0.114));
float a = clamp(amount, 0.0, 1.0);
vec3 mixed = pixel.rgb * (1.0 - a) + vec3(gray) * a;
return vec4(mixed, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- CHROMATIC ABERRATION
-- Uniforms: float amount (try 0.003 0.012)
-- ─────────────────────────────────────────────
NewShader("chromatic_aberration", [[
extern float amount;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 r = Texel(tex, tc + vec2(amount, 0.0));
vec4 g = Texel(tex, tc);
vec4 b = Texel(tex, tc + vec2(-amount, 0.0));
vec4 pixel = vec4(r.r, g.g, b.b, g.a) * color;
return pixel;
}
]])
-- ─────────────────────────────────────────────
-- GAUSSIAN BLUR (single-pass, 9-tap)
-- Run twice with {1,0} then {0,1} for full 2D blur.
-- Uniforms: vec2 direction ({1,0} or {0,1}), vec2 size
-- ─────────────────────────────────────────────
NewShader("blur", [[
extern vec2 direction;
extern vec2 size;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 px = direction / size;
vec4 result = vec4(0.0);
result += Texel(tex, tc - px * 4.0) * 0.0162;
result += Texel(tex, tc - px * 3.0) * 0.0540;
result += Texel(tex, tc - px * 2.0) * 0.1216;
result += Texel(tex, tc - px * 1.0) * 0.1945;
result += Texel(tex, tc ) * 0.2270;
result += Texel(tex, tc + px * 1.0) * 0.1945;
result += Texel(tex, tc + px * 2.0) * 0.1216;
result += Texel(tex, tc + px * 3.0) * 0.0540;
result += Texel(tex, tc + px * 4.0) * 0.0162;
return result * color;
}
]])
-- ─────────────────────────────────────────────
-- SCANLINES
-- Uniforms: float strength (0.0-1.0), float count (e.g. 200)
-- ─────────────────────────────────────────────
NewShader("scanlines", [[
extern float strength;
extern float count;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
float line = sin(tc.y * count * 3.14159) * 0.5 + 0.5;
float dim = 1.0 - strength * (1.0 - line);
return vec4(pixel.rgb * dim, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- PIXELATE
-- Uniforms: float pixels (grid cell size, e.g. 8.0), vec2 size
-- ─────────────────────────────────────────────
NewShader("pixelate", [[
extern float pixels;
extern vec2 size;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 grid = floor(tc * size / pixels) * pixels / size;
return Texel(tex, grid) * color;
}
]])
-- ─────────────────────────────────────────────
-- VIGNETTE
-- Uniforms: float intensity (0.0-1.0), float smoothness (0.0-1.0)
-- ─────────────────────────────────────────────
NewShader("vignette", [[
extern float intensity;
extern float smoothness;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec2 uv = tc - 0.5;
float dist = length(uv);
float vig = smoothstep(0.8, 0.8 - smoothness, dist * intensity);
return vec4(pixel.rgb * vig, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- HUE SHIFT
-- Uniforms: float hue (radians, 0 = no change)
-- ─────────────────────────────────────────────
NewShader("hue_shift", [[
extern float hue;
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec3 hsv = rgb2hsv(pixel.rgb);
hsv.x = fract(hsv.x + hue / 6.28318);
return vec4(hsv2rgb(hsv), pixel.a);
}
]])
--[[
NEEDS TESTING :P
]]
-- ─────────────────────────────────────────────
-- DISSOLVE
-- Uniforms: float threshold (0.0=visible, 1.0=gone)
-- float edge_width (e.g. 0.05)
-- vec4 edge_color
-- ─────────────────────────────────────────────
NewShader("dissolve", [[
extern float threshold;
extern float edge_width;
extern vec4 edge_color;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1,0)), u.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x), u.y);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
float n = noise(tc * 8.0);
if (n < threshold) discard;
if (n < threshold + edge_width) return edge_color;
return pixel;
}
]])
-- ─────────────────────────────────────────────
-- WAVE
-- Uniforms: float time, float amplitude (e.g. 0.01), float frequency (e.g. 10.0)
-- ─────────────────────────────────────────────
NewShader("wave", [[
extern float time;
extern float amplitude;
extern float frequency;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 uv = tc;
uv.x += sin(uv.y * frequency + time * 3.0) * amplitude;
uv.y += sin(uv.x * frequency + time * 2.5) * amplitude * 0.6;
return Texel(tex, uv) * color;
}
]])
-- ─────────────────────────────────────────────
-- RAINBOW / IRIDESCENCE
-- Uniforms: float time, float speed (e.g. 1.0), float spread (e.g. 2.0)
-- ─────────────────────────────────────────────
NewShader("rainbow", [[
extern float time;
extern float speed;
extern float spread;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
float h = fract(tc.x * spread + time * speed * 0.1);
vec3 rainbow = hsv2rgb(vec3(h, 0.8, 1.0));
return vec4(pixel.rgb * rainbow, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- FLASH / HIT-FLASH
-- Uniforms: float flash (0.0=normal, 1.0=full flash)
-- vec4 flash_color (e.g. {1,1,1,1})
-- ─────────────────────────────────────────────
NewShader("flash", [[
extern float flash;
extern vec4 flash_color;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec3 mixed = pixel.rgb * (1.0 - flash) + flash_color.rgb * flash;
return vec4(mixed, pixel.a);
}
]])
-- ─────────────────────────────────────────────
-- INVERT
-- Uniforms: float amount (0.0 = normal, 1.0 = fully inverted)
-- ─────────────────────────────────────────────
NewShader("invert", [[
extern float amount;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 pixel = Texel(tex, tc) * color;
vec3 inverted = 1.0 - pixel.rgb;
vec3 mixed = pixel.rgb * (1.0 - amount) + inverted * amount;
return vec4(mixed, pixel.a);
}
]])
-- Auto hooks new shaders to SHADERS namespace, also adds the NewShader function
return {init = function(gui) gui.SHADERS=shaders gui.NewShader = NewShader end}
-430
View File
@@ -1,430 +0,0 @@
-- gui_yaml.lua
-- Parses a YAML-like table (pre-parsed by a YAML lib) into GUI elements.
-- Usage: local yaml = require("tinyyaml") (or lyaml, etc.)
-- local def = yaml.parse(yaml_string)
-- local root = gui_yaml.build(gui, def)
local gui_yaml = {}
-- ─────────────────────────────────────────────
-- Helpers
-- ─────────────────────────────────────────────
local function parseColor(v)
if type(v) == "string" then
return require("gui.core.color").new(v)
elseif type(v) == "table" then
-- {r, g, b} or {r, g, b, a} — values 0-255 or 0-1
local r, g, b, a = v[1], v[2], v[3], v[4] or 255
-- normalise if in 0-255 range
if r > 1 or g > 1 or b > 1 then
r, g, b = r/255, g/255, b/255
if a > 1 then a = a/255 end
end
return {r, g, b, a}
end
return nil
end
local function parseDualDim(def)
--[[
YAML formats accepted:
pos: [x, y] offset
size: [w, h] offset
scale-pos: [sx, sy] scale (0-1)
scale-size: [sw, sh] scale (0-1)
Or shorthand flat:
x, y, w, h, sx, sy, sw, sh
]]
local px = def.x or (def.pos and def.pos[1]) or 0
local py = def.y or (def.pos and def.pos[2]) or 0
local pw = def.w or def.width or (def.size and def.size[1]) or 0
local ph = def.h or def.height or (def.size and def.size[2]) or 0
local sx = def.sx or (def["scale-pos"] and def["scale-pos"][1]) or 0
local sy = def.sy or (def["scale-pos"] and def["scale-pos"][2]) or 0
local sw = def.sw or (def["scale-size"] and def["scale-size"][1]) or 0
local sh = def.sh or (def["scale-size"] and def["scale-size"][2]) or 0
return px, py, pw, ph, sx, sy, sw, sh
end
local function applyShared(parent, obj, def)
-- Color / border
if def.color then obj.color = parseColor(def.color) end
if def["border-color"] then obj.borderColor = parseColor(def["border-color"]) end
if def["draw-border"] ~= nil then obj.drawBorder = def["draw-border"] end
-- Visibility
if def.visible ~= nil then obj.visible = def.visible end
if def.active ~= nil then obj.active = def.active end
if def.visibility ~= nil then obj.visibility = def.visibility end
-- Rotation
if def.rotation then obj.rotation = def.rotation end
-- Tag
if def.tag then obj:tag(def.tag) end
-- Tags (multi)
if def.tags then
for _, t in ipairs(def.tags) do obj:setTag(t) end
end
-- Form factor
if def.form then
local f = def.form
if f == "circle" then
local x, y, w, h, sx, sy, sw = parseDualDim(def)
local r = def.radius or (w / 2)
obj:makeCircle(x, y, r, sx, sy, sw, def.segments)
elseif f == "arc" then
local x, y, w, h, sx, sy, sw = parseDualDim(def)
local r = def.radius or (w / 2)
obj:makeArc(
def["arc-type"] or "open",
x, y, r, sx, sy, sw,
def["angle-start"] or 0,
def["angle-end"] or math.pi * 2,
def.segments
)
end
end
-- Roundness
if def.roundness then
local r = def.roundness
if type(r) == "table" then
obj:setRoundness(r[1], r[2], r[3], r.side)
elseif type(r) == "string" then
-- "top" | "bottom" shorthand
obj:setRoundness(5, 5, 30, r)
else
obj:setRoundness(r, r, 30)
end
end
-- Centering
if def["center-x"] then obj:centerX(def["center-x"]) end
if def["center-y"] then obj:centerY(def["center-y"]) end
-- Full frame shorthand
if def["full-frame"] then obj:fullFrame() end
-- Square lock
if def.square then obj.square = def.square end
-- Dragging
if def.draggable then
obj:enableDragging(
type(def.draggable) == "number" and def.draggable or 1
)
end
-- Hierarchy
if def["respect-hierarchy"] ~= nil then
obj:respectHierarchy(def["respect-hierarchy"])
end
-- Clip descendants
if def["clip-descendants"] ~= nil then
obj.clipDescendants = def["clip-descendants"]
end
-- Effects (function reference by name — looked up via _G or a registry)
if def.effect then
local fn = type(def.effect) == "function"
and def.effect
or _G[def.effect]
if fn then obj.effect = fn end
end
-- Shader (name → looked up in _G)
if def.shader then
obj.shader = type(def.shader) == "userdata"
and def.shader
or _G[def.shader]
end
-- Position on stack
if def.stack then
if def.stack == "top" then obj:topStack() end
if def.stack == "bottom" then obj:bottomStack() end
end
end
local function applyEvents(obj, def, env)
--[[
Events in YAML can be:
on-pressed: "myFunction" -- looks up _G or env
on-pressed: |
print("hello") -- raw Lua string, loaded as chunk
]]
local function resolve(v)
if type(v) == "function" then return v end
if type(v) == "string" then
-- Try global lookup first
if _G[v] and type(_G[v]) == "function" then return _G[v] end
-- Otherwise treat as Lua source
if env then
return env[v]
end
end
end
local map = {
["on-pressed"] = "OnPressed",
["on-released"] = "OnReleased",
["on-released-outer"] = "OnReleasedOuter",
["on-pressed-outer"] = "OnPressedOuter",
["on-enter"] = "OnEnter",
["on-exit"] = "OnExit",
["on-moved"] = "OnMoved",
["on-drag-start"] = "OnDragStart",
["on-dragging"] = "OnDragging",
["on-drag-end"] = "OnDragEnd",
["on-wheel"] = "OnWheelMoved",
["on-size-changed"] = "OnSizeChanged",
["on-position-changed"] = "OnPositionChanged",
["on-destroy"] = "OnDestroy",
["on-load"] = "OnLoad",
["on-return"] = "OnReturn", -- textbox only
}
for yaml_key, conn_key in pairs(map) do
if def[yaml_key] and obj[conn_key] then
local fn = resolve(def[yaml_key])
if fn then obj[conn_key](fn) end
end
end
-- on-update is special (not a connection)
if def["on-update"] then
local fn = resolve(def["on-update"])
if fn then obj:OnUpdate(fn) end
end
-- Hotkeys
if def.hotkeys then
for _, hk in ipairs(def.hotkeys) do
-- {keys: [lctrl, s], action: "mySaveFunction"}
local fn = resolve(hk.action)
if fn then obj:setHotKey(hk.keys)(fn) end
end
end
end
local function applyTextProps(obj, def)
if def.text then obj.text = tostring(def.text) end
if def["text-color"] then obj.textColor = parseColor(def["text-color"]) end
if def["text-visibility"] then obj.textVisibility = def["text-visibility"] end
if def["text-scale"] then
obj.textScaleX = def["text-scale"][1] or 1
obj.textScaleY = def["text-scale"][2] or 1
end
if def["text-offset"] then
obj.textOffsetX = def["text-offset"][1] or 0
obj.textOffsetY = def["text-offset"][2] or 0
end
if def["text-shear"] then
obj.textShearingFactorX = def["text-shear"][1] or 0
obj.textShearingFactorY = def["text-shear"][2] or 0
end
-- Alignment
local alignMap = {left = 1, center = 0, right = 2}
if def.align then
obj.align = alignMap[def.align] or 1
end
-- Font
if def.font then
local f = def.font
if type(f) == "number" then
obj:setFont(f)
elseif type(f) == "string" then
obj:setFont(f, def["font-size"])
elseif type(f) == "table" then
-- {file: "fonts/roboto.ttf", size: 18}
obj:setFont(f.file or f[1], f.size or f[2])
end
end
-- fit-font: true | {min: 8, max: 200, scale: 1}
if def["fit-font"] then
local ff = def["fit-font"]
if ff == true then
obj:fitFont()
elseif type(ff) == "table" then
obj:fitFont(ff.min, ff.max, ff.scale and {scale=ff.scale} or nil)
end
end
-- center-font: true | offset
if def["center-font"] then
local cf = def["center-font"]
obj:centerFont(type(cf) == "number" and cf or nil)
end
end
local function applyImageProps(obj, def)
-- source: "path/to/image.png"
-- tile: [x, y, w, h] (optional sub-quad)
if def.source then
if def.tile then
local t = def.tile
obj:setImage(def.source, t[1], t[2], t[3], t[4])
else
obj:setImage(def.source)
end
end
if def["scale-x"] then obj.scaleX = def["scale-x"] end
if def["scale-y"] then obj.scaleY = def["scale-y"] end
if def["image-color"] then obj.imageColor = parseColor(def["image-color"]) end
if def["image-visibility"] then obj.imageVisibility = def["image-visibility"] end
-- flip: "horizontal" | "vertical" | "both"
if def.flip then
local fl = def.flip
if fl == "horizontal" or fl == "both" then obj:flip(false) end
if fl == "vertical" or fl == "both" then obj:flip(true) end
end
-- gradient shorthand
if def.gradient then
local g = def.gradient
-- {direction: "vertical", colors: [[r,g,b,a], ...]}
local colors = {}
for _, c in ipairs(g.colors) do
colors[#colors+1] = parseColor(c)
end
obj:applyGradient(g.direction or "vertical", table.unpack(colors))
end
end
local function applyVideoProps(obj, def)
if def.source then obj:setVideo(def.source) end
if def.volume then obj:setVolume(def.volume) end
if def.autoplay and def.autoplay then obj:play() end
if def["video-color"] then obj.videoColor = parseColor(def["video-color"]) end
if def["video-visibility"] then obj.videoVisibility = def["video-visibility"] end
end
-- ─────────────────────────────────────────────
-- Core builder
-- ─────────────────────────────────────────────
local builders -- forward ref for recursion
builders = {
["frame"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newFrame(x,y,w,h,sx,sy,sw,sh)
end,
["virtual-frame"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newVirtualFrame(x,y,w,h,sx,sy,sw,sh)
end,
["visual-frame"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newVisualFrame(x,y,w,h,sx,sy,sw,sh)
end,
["label"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newTextLabel(def.text or "", x,y,w,h,sx,sy,sw,sh)
end,
["button"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newTextButton(def.text or "", x,y,w,h,sx,sy,sw,sh)
end,
["textbox"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newTextBox(def.text or "", x,y,w,h,sx,sy,sw,sh)
end,
["image"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newImageLabel(def.source, x,y,w,h,sx,sy,sw,sh)
end,
["image-button"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newImageButton(def.source, x,y,w,h,sx,sy,sw,sh)
end,
["video"] = function(parent, def)
local x,y,w,h,sx,sy,sw,sh = parseDualDim(def)
return parent:newVideo(def.source, x,y,w,h,sx,sy,sw,sh)
end,
}
local TEXT_TYPES = {label=true, button=true, textbox=true}
local IMAGE_TYPES = {image=true, ["image-button"]=true}
local VIDEO_TYPES = {video=true}
function gui_yaml.build(parent, def, env)
--[[
parent : gui element or gui root
def : parsed YAML table (one element)
env : optional Lua env table for event string resolution
Returns the created object (or nil on unknown type).
]]
local typ = def.type
if not typ then
error("gui_yaml: element missing 'type' field")
end
local builder = builders[typ]
if not builder then
error("gui_yaml: unknown element type '" .. tostring(typ) .. "'")
end
local obj = builder(parent, def)
-- Apply shared properties
applyShared(parent, obj, def)
-- Apply type-specific properties
if TEXT_TYPES[typ] then applyTextProps(obj, def) end
if IMAGE_TYPES[typ] then applyImageProps(obj, def) end
if VIDEO_TYPES[typ] then applyVideoProps(obj, def) end
-- Events
applyEvents(obj, def, env)
-- Recurse into children
if def.children then
for _, child_def in ipairs(def.children) do
gui_yaml.build(obj, child_def, env)
end
end
return obj
end
function gui_yaml.buildMany(parent, defs, env)
local results = {}
for _, def in ipairs(defs) do
results[#results+1] = gui_yaml.build(parent, def, env)
end
return results
end
-- Convenience: parse a YAML string and build in one call.
-- Requires a YAML library. Tries tinyyaml, then lyaml.
function gui_yaml.fromString(parent, yaml_str, env)
local ok, yaml = pcall(require, "gui.yaml.tinyyaml")
if not ok then
ok, yaml = pcall(require, "lyaml")
if not ok then
error("gui_yaml.fromString: no YAML library found (tried tinyyaml, lyaml)")
end
end
local def = yaml.parse(yaml_str)
-- Support both single-element and list-of-elements at root
if def.type then
return gui_yaml.build(parent, def, env)
else
return gui_yaml.buildMany(parent, def, env)
end
end
return gui_yaml
-362
View File
@@ -1,362 +0,0 @@
local function colorToYaml(c)
if not c then return nil end
-- Check if it's a color object with hex method, otherwise use raw values
if type(c) == "table" then
if c.toHex then return c:toHex() end
-- Normalise to 0-255 for readability
local r = c[1] or 0
local g = c[2] or 0
local b = c[3] or 0
local a = c[4]
if r <= 1 and g <= 1 and b <= 1 then
r, g, b = math.floor(r*255), math.floor(g*255), math.floor(b*255)
if a then a = math.floor(a*255) end
end
if a and a < 255 then
return string.format("[%d, %d, %d, %d]", r, g, b, a)
end
return string.format("[%d, %d, %d]", r, g, b)
end
return nil
end
local function dualDimToYaml(obj)
local dd = obj.dualDim
local fields = {}
local op = dd.offset.pos
local os = dd.offset.size
local sp = dd.scale.pos
local ss = dd.scale.size
if op.x ~= 0 then fields[#fields+1] = {"x", op.x} end
if op.y ~= 0 then fields[#fields+1] = {"y", op.y} end
if os.x ~= 0 then fields[#fields+1] = {"w", os.x} end
if os.y ~= 0 then fields[#fields+1] = {"h", os.y} end
if sp.x ~= 0 then fields[#fields+1] = {"sx", sp.x} end
if sp.y ~= 0 then fields[#fields+1] = {"sy", sp.y} end
if ss.x ~= 0 then fields[#fields+1] = {"sw", ss.x} end
if ss.y ~= 0 then fields[#fields+1] = {"sh", ss.y} end
return fields
end
local bit = require("bit")
local band = bit.band
local frame, image, text, box, video, button, anim = 0, 1, 2, 4, 8, 16, 32
local function resolveTypeName(typ)
-- Match in specificity order (combined types first)
if typ == text + box then return "textbox" end
if typ == text + button then return "button" end
if typ == text + frame then return "label" end
if typ == image + frame then return "image" end
-- image+button uses frame internally in this lib
-- (newImageButton sets type = image+frame but adds cursor behaviour)
-- We detect image buttons by checking for cursor handler presence;
-- as a fallback we use "image" and the loader will still reconstruct it.
if band(typ, video) == video then return "video" end
if band(typ, image) == image then return "image" end
if typ == frame then return "frame" end
return "frame" -- safe fallback
end
local alignNames = {[0]="center", [1]="left", [2]="right"}
local formNames = {
[1] = "rectangle",
[2] = "circle",
[3] = "arc",
}
-- YAML emitter — produces clean, human-readable YAML without a library dep.
local function emit(val, indent, visited)
indent = indent or 0
visited = visited or {}
local pad = string.rep(" ", indent)
local t = type(val)
if t == "boolean" then return tostring(val) end
if t == "number" then
-- Avoid scientific notation for small floats
if val == math.floor(val) then return string.format("%d", val) end
return string.format("%.6g", val)
end
if t == "string" then
-- Quote if contains special YAML chars or is empty
if val == "" or val:match("^[%s#&*!|>'\"%[%]{},?:-]") or val:match("[\n\r]") then
-- Escape inner quotes, wrap in double quotes
return '"' .. val:gsub('"', '\\"'):gsub("\n", "\\n") .. '"'
end
return val
end
if t ~= "table" then return tostring(val) end
-- Cycle guard
if visited[val] then return '"<cycle>"' end
visited[val] = true
-- Detect plain array (sequential integer keys starting at 1)
local isArray = true
local maxN = 0
for k, _ in pairs(val) do
if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then
isArray = false
break
end
if k > maxN then maxN = k end
end
if isArray and maxN ~= #val then isArray = false end
local lines = {}
if isArray then
-- Inline short numeric/string arrays on one line
local allScalar = true
for _, v in ipairs(val) do
if type(v) == "table" then allScalar = false; break end
end
if allScalar and #val <= 6 then
local parts = {}
for _, v in ipairs(val) do parts[#parts+1] = emit(v, 0, visited) end
visited[val] = nil
return "[" .. table.concat(parts, ", ") .. "]"
end
for _, v in ipairs(val) do
local rendered = emit(v, indent + 1, visited)
if type(v) == "table" then
lines[#lines+1] = pad .. "-\n" .. rendered
else
lines[#lines+1] = pad .. "- " .. rendered
end
end
else
for _, pair in ipairs(val) do
local k, v = pair[1], pair[2]
local rendered = emit(v, indent + 1, visited)
if type(v) == "table" and #v > 0 and type(v[1]) == "table" then
-- Nested block (list of pairs = mapping, or list of items)
lines[#lines+1] = pad .. k .. ":\n" .. rendered
elseif type(v) == "table" and type(v[1]) ~= "table" then
-- Inline array
lines[#lines+1] = pad .. k .. ": " .. rendered
else
lines[#lines+1] = pad .. k .. ": " .. rendered
end
end
end
visited[val] = nil
return table.concat(lines, "\n")
end
-- ─────────────────────────────────────────────────────────────────────────────
-- Main export function
-- ─────────────────────────────────────────────────────────────────────────────
local function guiToYaml(obj, opts)
--[[
opts = {
indent = 0, -- starting indent level
skipDefaults = true, -- omit fields that equal their default values
includeChildren = true, -- recurse into children
eventNames = {}, -- map of connection object -> string name
-- e.g. {[obj.OnPressed] = "handlePress"}
}
--]]
opts = opts or {}
local skipDef = opts.skipDefaults ~= false -- default true
local inclChild = opts.includeChildren ~= false -- default true
local indent = opts.indent or 0
local evNames = opts.eventNames or {}
-- Ordered list of {key, value} pairs — order controls YAML output order
local fields = {}
local function add(k, v)
if v == nil then return end
if skipDef then
-- Skip booleans that match common defaults
if k == "visible" and v == true then return end
if k == "active" and v == true then return end
if k == "visibility" and v == 1 then return end
if k == "draw-border" and v == true then return end
if k == "rotation" and v == 0 then return end
if k == "align" and v == "left" then return end
if k == "text-visibility" and v == 1 then return end
if k == "image-visibility" and v == 1 then return end
if k == "video-visibility" and v == 1 then return end
if k == "scale-x" and v == 1 then return end
if k == "scale-y" and v == 1 then return end
end
fields[#fields+1] = {k, v}
end
-- ── Type ──────────────────────────────────────────────────────────
add("type", resolveTypeName(obj.type))
-- ── Dual dimensions ───────────────────────────────────────────────
for _, pair in ipairs(dualDimToYaml(obj)) do
add(pair[1], pair[2])
end
-- ── Shared appearance ─────────────────────────────────────────────
local col = colorToYaml(obj.color)
if col and col ~= "[142, 141, 141]" then -- skip library default grey
add("color", col)
end
local bcol = colorToYaml(obj.borderColor)
if bcol and bcol ~= "[0, 0, 0]" then
add("border-color", bcol)
end
add("draw-border", obj.drawBorder)
add("visible", obj.visible)
add("active", obj.active)
if obj.visibility ~= 1 then add("visibility", obj.visibility) end
if obj.rotation ~= 0 then add("rotation", obj.rotation) end
-- ── Tag / tags ────────────────────────────────────────────────────
if obj.__tag then add("tag", obj.__tag) end
if obj.tags then
local tagList = {}
for t, _ in pairs(obj.tags) do tagList[#tagList+1] = t end
if #tagList > 0 then add("tags", tagList) end
end
-- ── Form factor ───────────────────────────────────────────────────
local ff = obj.formFactor or 1
if ff ~= 1 then -- skip default "rectangle"
add("form", formNames[ff] or "rectangle")
if obj.__radius then add("radius", obj.__radius) end
if obj.segments then add("segments", obj.segments) end
if ff == 3 then
add("arc-type", obj.arcType or "open")
add("angle-start", obj.__angleS)
add("angle-end", obj.__angleE)
end
end
-- ── Roundness ─────────────────────────────────────────────────────
if obj.roundness then
local r = obj.roundness
if r == true then
-- generic — emit the rx/ry/segments triple
add("roundness", {obj.__rx or 5, obj.__ry or 5, obj.__segments or 30})
elseif type(r) == "string" then
add("roundness", r) -- "top" or "bottom"
end
end
-- ── Behaviour flags ───────────────────────────────────────────────
if obj.clipDescendants then add("clip-descendants", true) end
if obj.square then add("square", obj.square) end
-- ── Text-type fields ──────────────────────────────────────────────
if band(obj.type, text) == text then
if obj.text and obj.text ~= "" then add("text", obj.text) end
local al = alignNames[obj.align]
add("align", al)
local tc = colorToYaml(obj.textColor)
if tc and tc ~= "[0, 0, 0]" then add("text-color", tc) end
if obj.textVisibility ~= 1 then add("text-visibility", obj.textVisibility) end
if obj.textScaleX ~= 1 or obj.textScaleY ~= 1 then
add("text-scale", {obj.textScaleX, obj.textScaleY})
end
if obj.textOffsetX ~= 0 or obj.textOffsetY ~= 0 then
add("text-offset", {obj.textOffsetX, obj.textOffsetY})
end
if obj.textShearingFactorX ~= 0 or obj.textShearingFactorY ~= 0 then
add("text-shear", {obj.textShearingFactorX, obj.textShearingFactorY})
end
-- Font: emit as {file, size} when a file path is known
if obj.font then
if obj.fontFile then
add("font", {{"file", obj.fontFile}, {"size", obj.font:getHeight()}})
else
add("font", obj.font:getHeight())
end
end
end
-- ── Image-type fields ─────────────────────────────────────────────
if band(obj.type, image) == image and band(obj.type, video) ~= video then
-- Source path is stored via getSource()
local src = obj:getSource and obj:getSource()
if src then add("source", src) end
if obj.scaleX ~= 1 then add("scale-x", obj.scaleX) end
if obj.scaleY ~= 1 then add("scale-y", obj.scaleY) end
local ic = colorToYaml(obj.imageColor)
if ic and ic ~= "[255, 255, 255]" then add("image-color", ic) end
if obj.imageVisibility and obj.imageVisibility ~= 1 then
add("image-visibility", obj.imageVisibility)
end
end
-- ── Video-type fields ─────────────────────────────────────────────
if band(obj.type, video) == video then
local src = obj:getSource and obj:getSource()
if src then add("source", src) end
local vc = colorToYaml(obj.videoColor)
if vc and vc ~= "[255, 255, 255]" then add("video-color", vc) end
if obj.videoVisibility and obj.videoVisibility ~= 1 then
add("video-visibility", obj.videoVisibility)
end
if obj.audiosource then
add("volume", obj.audiosource:getVolume())
end
if obj.playing then add("autoplay", true) end
end
-- ── Events ────────────────────────────────────────────────────────
-- We can only serialise events when the caller supplies a name map.
-- Otherwise we silently skip them (can't decompile closures).
local connMap = {
["on-pressed"] = obj.OnPressed,
["on-released"] = obj.OnReleased,
["on-released-outer"] = obj.OnReleasedOuter,
["on-pressed-outer"] = obj.OnPressedOuter,
["on-enter"] = obj.OnEnter,
["on-exit"] = obj.OnExit,
["on-moved"] = obj.OnMoved,
["on-drag-start"] = obj.OnDragStart,
["on-dragging"] = obj.OnDragging,
["on-drag-end"] = obj.OnDragEnd,
["on-wheel"] = obj.OnWheelMoved,
["on-size-changed"] = obj.OnSizeChanged,
["on-position-changed"] = obj.OnPositionChanged,
["on-destroy"] = obj.OnDestroy,
["on-load"] = obj.OnLoad,
["on-return"] = obj.OnReturn,
}
for yamlKey, conn in pairs(connMap) do
if conn and evNames[conn] then
add(yamlKey, evNames[conn])
end
end
-- ── Children ──────────────────────────────────────────────────────
if inclChild and obj.children and #obj.children > 0 then
local childDefs = {}
for _, child in ipairs(obj.children) do
-- Recurse, collect as ordered-pair tables for the emitter
local childFields = guiToYaml(child, {
skipDefaults = opts.skipDefaults,
includeChildren = opts.includeChildren,
eventNames = opts.eventNames,
_returnRaw = true, -- internal: return fields table, not string
})
childDefs[#childDefs+1] = childFields
end
fields[#fields+1] = {"children", childDefs}
end
-- Internal mode: return the raw ordered-pair table for parent to embed
if opts._returnRaw then return fields end
return emit(fields, indent)
end
return guiToYaml
-59
View File
@@ -1,59 +0,0 @@
# login_dialog.yaml
type: frame
x: 0
y: 0
scale-size: [0.4, 0.25]
scale-pos: [0.3, 0.2]
color: "#1e1e2e"
border-color: "#4a4a6a"
roundness: 12
children:
- type: label
text: "Sign In"
x: 0
y: 20
scale-size: [1.0, 0.0]
h: 48
align: center
font: 28
text-color: "#cdd6f4"
- type: textbox
x: 30
y: 90
scale-size: [0.85, 0.0]
h: 36
text: ""
color: "#313244"
border-color: "#6c7086"
text-color: "#cdd6f4"
font: 14
on-return: "focusPassword"
- type: textbox
x: 30
y: 140
scale-size: [0.85, 0.0]
h: 36
text: ""
color: "#313244"
border-color: "#6c7086"
text-color: "#cdd6f4"
font: 14
on-return: "submitLogin"
- type: button
text: "Log In"
align: "center"
fit-font: true
x: 30
y: 200
scale-size: [0.2, 0.0]
h: 40
color: "#89b4fa"
text-color: "#1e1e2e"
roundness: 8
on-pressed: "submitLogin"
on-enter: "highlightButton"
on-exit: "unhighlightButton"
+65 -22
View File
@@ -1,40 +1,83 @@
local gui, color, theme, utils, board, yaml, loader, scoreUpdater
local logger = require("utils/logger")
local lovehandler = require("utils/love_file_handler")
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
GLOBAL, THREAD = require("multi.integration.loveManager"):init()
gui = require("gui")
color = require("gui.core.color")
theme = require("gui.core.theme")
utils = require("utils")
board = require("board")
yaml = require("yaml")
loader = require("loader")
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.DEBUG,
handlers = {
logger.handlers.ConsoleHandler({ colours = true }),
fileHandler,
},
})
local gui = require("gui")
local color = require("gui.core.color")
local title = require("menus.title")
local menu = require("gui.core.menus")
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")
require("gui.addons")
title = require("menus.title")
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 love.load()
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
local bg = gui:newFrame()
loadLog:debug("Creating main background image")
local bg = gui:newImageLabel("assets/images/background.png")
bg:fullFrame()
bg.color = color.new("#242f9b")
require("menus.menus").init(bg)
loadLog:debug("Init menu module")
menu.init(bg)
loadLog:debug("Displaying title menu")
title:visible(true)
bg:OnUpdate(function(self, dt)
if self.__blur then
-- pulse the blur between 2 and 12
local t = love.timer.getTime()
self:setBlurRadius(7 + 5 * math.sin(t * 2))
end
end)
end
function love.update(dt)
+211 -127
View File
@@ -1,47 +1,28 @@
local gui = require("gui")
local color = require("gui.core.color")
local loader = require("loader")
local loader = require("utils/loader")
local theme = require("gui.core.theme")
local fmt = require("fmt")
local timer = require("utils")
local fmt = require("utils/fmt")
local timer = require("utils/utils")
local multi, thread = require("multi"):init()
local menu = require("menus.menus")
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")
boardUpdater.Start()
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
local function resizeFonts()
-- Use multi-threading to improve performance
multi:newThread(function()
-- Introduce a small delay to avoid too many iterations at once
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)
end
gui.Events.OnResized(resizeFonts)
boardLog:debug("Creating resize fonts handler")
local completed_questions = 0
local min_questions = 0
@@ -50,14 +31,6 @@ local dd_enabled = false
local applied_dd = false
local board, question
function gui:cleanup()
for i = #self.children, 1, -1 do
self.children[i]:destroy()
end
self.children = {}
completed_questions = completed_questions + 1
end
local function pickUniqueIndices(t, count)
assert(#t >= count, "Not enough elements to pick " .. count .. " unique items")
@@ -94,26 +67,39 @@ 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
print(i,v)
v.isDouble = true
end
applied_dd = true
end
end
local qUpdater = gui:getProcessor():newProcessor("question-updater")
qUpdater.Start()
local templateLog = log:child("template")
function LoadTemplate(name, path)
if love.filesystem.getInfo("templates/" .. name .. ".lua") then
data = love.filesystem.read("templates/" .. name .. ".lua")
elseif love.filesystem.getInfo(path .."/templates/" .. name .. ".lua") then
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)
@@ -154,7 +140,8 @@ function LoadTemplate(name, path)
gui = gui,
timer = timer,
-- love stuff
newSource = love.audio.newSource
newSource = love.audio.newSource,
log = templateLog
}
env._G = env
@@ -163,22 +150,89 @@ function LoadTemplate(name, path)
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)
local qframe = frame:newFrame(0, 0, 0, 0, .2, .05, .75, .9)
qframe.color = color.new("#060ee9")
local scoreboard = ScoreBoard(frame, 0, 0, 0, 0, .015, .05, .170, .9)
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)
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.new("#060ce9")
question.color = color.question_bg
local tiers = index.settings.tiers or 5
local start = index.settings.start or 100
@@ -190,6 +244,29 @@ function Menu(frame, path)
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
@@ -197,26 +274,31 @@ function Menu(frame, path)
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.new("#ffffff")
c.color = color.new("#060ce9")
c.textColor = color.header_font
c.color = color.header_tile
end
c.UUID = multi.generate_uuid7() -- Each otpion gets a unique UUID
img = c:newImageButton("assets/images/placeholder.jpg")
img.visibility = 0
if not autoreveal then
img = c:newImageButton("assets/images/placeholder.jpg")
img.visibility = 0
img:OnReleased(function(self)
self.visible = false
end)
img:OnReleased(function(self)
self.visible = false
end)
img:fullFrame()
table.insert(manage,c)
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.new("#9b9024")
t.textColor = color.tile_font
t.align = gui.ALIGN_CENTER
t.color = color.new("#060ce9")
t.color = color.tile_bg
t.category = v.name
t.index = tier
t.price = start + inc*(tier-1)
@@ -225,16 +307,31 @@ function Menu(frame, path)
if dd_enabled then
applyDD() -- check and run DD if conditions meet
end
if index.categories[cat].questions == nil then fmt.Printf("Question not defined: File: %v Category: %v - %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return 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 q == nil then fmt.Printf("Question contains no data: File: %v Category: %v Tier: %v\n",path,index.categories[cat].name,start + inc*(tier-1)) return end
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
fmt.Printf("--------------------\nQuestion: %v \nAnswer: %v\n--------------------\n",q["title"],q["answer"])
boardLog:info("Question: '%s' Answer: '%s'",q["title"],q["answer"])
local mul = 1
boardUpdater:newThread(function()
if self.isDouble then
@@ -255,7 +352,8 @@ function Menu(frame, path)
end)
end
local finished = false
template.index(question, q, function(ans)
template.index(question, q, function(ans, m)
local m = m or 1
if finished then return end
player = GetActivePlayer()
if tm then
@@ -263,41 +361,37 @@ function Menu(frame, path)
end
stop = true
if ans then
player:Add(self.price*mul)
player:Add((self.price*mul)*m)
finished = true
question.visible = false
question:cleanup()
reset:Fire()
elseif ans == false then
player:Add(-self.price*mul)
player = GetNextPlayer()
else
finished = true
question.visible = false
question:cleanup()
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.yield()
thread.sleep(.1)
if self.text == "" then return end
end
end)
end)
end))
table.insert(manage,t)
t:centerFont()
t:scaleFont(.5)
end
end
resizeFonts()
resizeFonts()
return background
end
scoreUpdater = gui:getProcessor():newProcessor("score-updater")
scoreUpdater.Start()
function GetActivePlayer()
if not activePlayer then return end
@@ -332,19 +426,33 @@ end
function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
-- Colors
local C_BG_PANEL = color.new("#1a1a2e")
local C_BG_HEADER = color.new("#16213e")
local C_ACCENT = color.new("#e94560")
local C_ROW_TOP = color.new("#1c2641")
local C_ROW_NORM = color.new("#121226")
local C_BORDER_TOP = color.new("#323c6e")
local C_BORDER_NRM = color.new("#1c1c37")
local C_BAR_EMPTY = color.new("#232341")
local C_TEXT_MUTED = color.new("#786e96")
local C_WHITE = color.new("#ffffff")
local C_GOLD = color.new("#ffd700")
local C_SILVER = color.new("#C0C0C0")
local C_BRONZE = color.new("#cd7f32")
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
@@ -380,34 +488,20 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
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
local updateList = {headernum, headerplayer, headerscore}
header:centerFont()
header:scaleFont(2/3)
for _,object in pairs(updateList) do
object:fitFont(nil, nil, {scale = 5/6})
object:centerFont()
end
function scoreboard:AddPlayer(name, score, icon)
local player = {
Name = name,
Score = score,
Score = score or 0,
Icon = icon,
UUID = multi.generate_uuid7(),
Add = function(self, amt)
@@ -419,13 +513,11 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
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
@@ -444,14 +536,7 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
remove_player:setFont(20)
remove_player.align = gui.ALIGN_CENTER
local embededWatch = {remove_player}
scoreUpdater:newThread(function()
while true do
thread.sleep(.01)
for i,v in pairs(embededWatch) do
v:centerFont()
end
end
end)
remove_player:centerFont()
local function embedTextEdit(reference, default, but_text, callback)
reference.color = C_BORDER_NRM
@@ -471,10 +556,9 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
end)
gui.apply({
setFont = {20},
align = gui.ALIGN_CENTER
align = gui.ALIGN_CENTER,
centerFont = {},
},textbox,button,reference)
table.insert(embededWatch,textbox)
table.insert(embededWatch,button)
end
remove_player.color = color.new("#a13a3a")
@@ -576,7 +660,9 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
gui.apply({
visibility = 0,
drawBorder = false,
textColor = MapColor(index)
textColor = MapColor(index),
scaleFont = {5/6},
centerFont = {}
},playernum, playerName, playerScore, playerIcon, playerLine)
player.Ref = {playerName, playerScore, playernum, playerIcon, playerLine, Frame = playerFrame}
@@ -584,8 +670,6 @@ function ScoreBoard(frame, x, y, w, h, sx, sy, sw, sh)
end
end
gui.Events.OnResized(ScoreResize)
ScoreResize()
return scoreboard
end
-54
View File
@@ -1,54 +0,0 @@
local gui = require("gui")
local menu = {}
local menus = {}
local frame
local menuRef = {}
local mt = {
__index = menuRef
}
function menu.registerMenu(name, menu, delete)
local c = {}
c.MenuFunc = menu
c.Delete = delete
setmetatable(c, mt)
menus[name] = c
return c
end
function menu.getMenu(name)
return menus[name] or nil, "Menu ".. name .. " not found."
end
function menu.init(f)
frame = f
end
function menu.hideMenus()
for k,v in pairs(menus) do
if v.frame then
if v.Delete then
v.frame:destroy()
menus[k] = nil
else
v.frame.visible = false
v.frame.active = false
v.frame:setParent(gui.virtual)
end
end
end
end
function menuRef:visible(value, ...)
menu.hideMenus()
if self.__menuConfigured and self.frame then
self.frame.visible = true
self.frame.active = true
self.frame:setParent(frame)
else
self.__menuConfigured = true
self.frame = self.MenuFunc(frame, ...)
end
end
return menu
+1 -1
View File
@@ -1,6 +1,6 @@
local gui = require("gui")
local color = require("gui.core.color")
local menu = require("menus.menus")
local menu = require("gui.core.menus")
-- local proc = gui:getProcessor():newProcessor("settings")
function Menu(frame)
+54 -21
View File
@@ -1,16 +1,21 @@
local gui = require("gui")
local color = require("gui.core.color")
local menu = require("menus.menus")
local menu = require("gui.core.menus")
local proc = gui:getProcessor():newProcessor("main-title")
local APP_VERSION = love.filesystem.read("version.txt")
local board = require("menus.board")
local titleLog = log:child("title")
function Menu(frame)
local background = frame:newImageLabel("assets/images/background.jpg")
background:fullFrame()
local title = frame:newTextLabel("Jeopardy",0,0,0,0,0,.1,1,.25)
local title = background:newTextLabel("Jeopardy",0,0,0,0,0,.1,1,.25)
title:setFont("assets/fonts/gyparody.ttf",300)
titleLog:debug("Setting title shader")
title:setShader(gui.SHADERS.vignette,{
intensity = .5,
smoothness = .8
@@ -18,18 +23,11 @@ function Menu(frame)
title.align = gui.ALIGN_CENTER
title.visibility = 0
title.textColor = color.new("#b16d24")
title.textColor = color.title_font
title.drawBorder = false
title:centerFont()
proc:newThread(function()
local function enabled()
return background.visible
end
while true do
thread.hold(enabled)
title:centerFont()
end
end)
titleLog:debug("Creating font thread")
local items = 0
local MenuOption = function(str)
@@ -37,13 +35,10 @@ function Menu(frame)
local text = item:newTextLabel(str)
text:fullFrame()
text.align = gui.ALIGN_CENTER
thread:newThread(function()
thread.skip(2)
text:fitFont(nil,nil,{scale = 1/2})
text:centerFont()
text.visibility = 0
text.textColor = color.new("#24269a")
end)
text:scaleFont(.4)
text:centerFont()
text.visibility = 0
text.textColor = color.new("#24269a")
items = items + 1
return item, text
end
@@ -62,10 +57,48 @@ function Menu(frame)
},play,settings,quit)
play:OnReleased(function()
background.visible = false
menu.getMenu("board"):visible(true,"anime")
titleLog:debug("Play button pressed")
if love.filesystem.getInfo("quiz") then
menu.getMenu("board"):visible(true, "quiz")
return
else
gui:newFilePicker("File picker test",nil, {".zip"}, function(file)
titleLog:info("File picked: %s", file)
menu.getMenu("board"):visible(true, file:gsub("\\","/"):gsub(love.filesystem.getSaveDirectory(),""):sub(2,-1))
end)
end
end)
quit:OnReleased(function(self, x, y)
print("Quit Pressed",x,y,self:getAbsolutes())
titleLog:debug("Quit button pressed")
local f = frame:newFrame()
f:fullFrame()
f.visibility = .8
f.color = color.black
local cm = menu.getCurrentMenu()
cm:setTag("visual")
quit:setShader()
gui:newMessageBox({
title = "Confirm",
message = "Are you sure you want to exit the game?",
buttons = { "Yes", "No" },
onChoice = function(label, idx)
if label == "Yes" then os.exit() end
cm:removeTag("visual")
f:destroy()
end,
})
end)
local version = frame:newTextLabel("Version: " .. APP_VERSION, -150,-30, 150, 30, 1, 1)
thread:newThread(function()
thread.skip(2)
version:fitFont()
end)
version.textColor = color.new("#b16d24")
version.visibility = 0
return background
end
+1 -1
Submodule multi updated: ee5e3bde7f...8b5d10cbb6
+19
View File
@@ -0,0 +1,19 @@
:: Read the first line of version.txt and assign it to the MY_VERSION variable
for /f "usebackq delims=" %%i in ("version.txt") do (
set "userInput=%%i"
goto :done
)
:done
"C:\Program Files\7-Zip\7z.exe" a -tzip game.love templates version.txt assets multi\integration\loveManager multi\integration\sharedExtensions multi\init.lua utils gui menus conf.lua main.lua
rd /s /q "bin\%userInput%"
if not exist "bin\%userInput%" mkdir "bin\%userInput%"
copy /b love\love.exe + game.love "bin\%userInput%\Jeopardy.exe"
copy /b love\love.dll "bin\%userInput%\love.dll"
copy /b lovel\lua51.dll "bin\%userInput%\lua51.dll"
copy /b love\mpg123.dll "bin\%userInput%\mpg123.dll"
copy /b love\msvcp120.dll "bin\%userInput%\msvcp120.dll"
copy /b love\msvcr120.dll "bin\%userInput%\msvcr120.dll"
copy /b love\OpenAL32.dll "bin\%userInput%\OpenAL32.dll"
copy /b love\SDL2.dll "bin\%userInput%\SDL2.dll"
copy /b love\license.txt "bin\%userInput%\license.txt"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

+6 -4
View File
@@ -9,8 +9,9 @@ local function index(window, q, callback)
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
label.borderColor = color.new("#060ce9")
label.color = color.new("#363ac8")
label.borderColor = color.new("#363ac8")
label:scaleFont(.075)
if not q.imageA or q.imageA == "" then
error("Missing 'imageA' field for question!")
@@ -49,7 +50,8 @@ local function index(window, q, callback)
}, imageHolder, plusLabel, imageHolder2)
window.apply({
fitFont={},
scaleFont={.7},
centerFont={},
align=window.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
@@ -62,7 +64,7 @@ local function index(window, q, callback)
end
local function update(dt)
label:fitFont()
end
return {
+8 -15
View File
@@ -21,10 +21,13 @@ local function index(window, q, callback)
label = window:newTextLabel(" " ..q.title.. " ",noOf(0,0,1,.3))
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
label.color = color.new("#363ac8")
label:setFont(50)
choices = window:newFrame(noOf(0,.3,1,.7))
choices.color = color.new("#060ce9")
label:centerFont()
label:scaleFont(2/3)
local choices = window:newFrame(noOf(0,.3,1,.7))
choices.color = color.new("#363ac8")
function choices:newChoice(choice, i)
local c = choices:newFrame(noOf(.25,(i-1)/#q.choices,.5,1/#q.choices))
@@ -52,9 +55,8 @@ local function index(window, q, callback)
choiceText.textColor = color.white
-- choiceText.align = window.ALIGN_CENTER
choiceText:setFont(40)
table.insert(choiceList,choiceText)
choiceText:centerFont()
table.insert(allBoxes,box)
end
for i,choice in pairs(q.choices) do
@@ -84,16 +86,7 @@ local function index(window, q, callback)
end
local function update(dt) -- time in seconds that has passed since
-- label:fitFont()
label:centerFont()
for _, obj in pairs(choiceList) do
obj:centerFont()
-- obj:fitFont(nil, nil, {scale = 2/3})
end
confirm:fitFont(nil, nil, {scale = 2/3})
confirm:centerFont()
-- print(box.parent:getAbsolutes())
-- print(box:getAbsolutes())
end
return {
+5 -3
View File
@@ -15,7 +15,9 @@ local function index(window, q, callback)
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
label.color = color.new("#363ac8")
label:scaleFont(.5)
label:centerFont()
if not q.image or q.image == "" then
error("Missing 'image' field for question!")
end
@@ -31,7 +33,8 @@ local function index(window, q, callback)
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
window.apply({
fitFont={},
scaleFont={.7},
centerFont={},
align=window.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
@@ -47,7 +50,6 @@ local function update(dt) -- time in seconds that has passed since
_,_,w,h = imageHolder.parent:getAbsolutes()
local x,y,w,h = imageHolder:GetSizeAdjustedToAspectRatio(w,h)
imageHolder:setDualDim(w,h,x,y)
label:fitFont()
end
return {
+6 -4
View File
@@ -13,7 +13,9 @@ local function index(window, q, callback)
label.align = ALIGN_CENTER
label:fullFrame()
label.textColor = color.white
label.color = color.new("#060ce9")
label.color = color.new("#363ac8")
label:centerFont()
label:scaleFont(.075)
local correct = window:newTextButton("Correct",0,-200,0,100,0,1,.5)
correct.color = color.new("#52b11b")
local wrong = window:newTextButton("Wrong",0,-200,0,100,.5,1,.5)
@@ -21,7 +23,8 @@ local function index(window, q, callback)
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
gui.apply({
fitFont={},
scaleFont={.7},
centerFont={},
align=gui.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
@@ -31,11 +34,10 @@ local function index(window, q, callback)
callback(self.text == "Correct")
end,
},correct,wrong,skip)
label:setFont(60)
end
local function update(dt) -- time in seconds that has passed since
label:centerFont()
end
return {
+1 -1
View File
@@ -20,7 +20,7 @@ local function index(window, q, callback)
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
label.color = color.new("#363ac8")
imageHolder = frame:newImageButton("assets/images/speaker.png",0,0,0,0,0,0,.3,.4)
imageHolder:setAspectSize(imageHolder.imageWidth,imageHolder.imageHeight)
imageHolder:centerX(true)
+6 -4
View File
@@ -20,7 +20,9 @@ local function index(window, q, callback)
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#060ce9")
label.color = color.new("#363ac8")
label:centerFont()
label:scaleFont(.5)
local holder = frame:newFrame(-300, -200, 600, 400, .5, .5)
holder:centerX(true)
holder:centerY(true)
@@ -38,7 +40,8 @@ local function index(window, q, callback)
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
window.apply({
fitFont={},
scaleFont={.7},
centerFont={},
align=window.ALIGN_CENTER,
OnReleased=function(self)
video:stop()
@@ -52,8 +55,7 @@ local function index(window, q, callback)
end
local function update(dt) -- time in seconds that has passed since
label:fitFont()
label:centerFont()
end
return {
+98
View File
@@ -0,0 +1,98 @@
--[[ Constants
* (all lua builtins that don't allow io/executing code)
color (interface)
gui (interface)
multi (interface bound to a processor) no thread module
callback true/false correct/wrong
]]
local label
local imageHolder
local function index(window, q, callback)
frame = window:newFrame(0,0,0,-200,0,.2,1,.8)
frame.visibility = 0
label = window:newTextLabel(" " ..q.title.. " ",0,0,0,0,0,0,1,.2)
label.align = ALIGN_CENTER
label.textColor = color.white
label.color = color.new("#363ac8")
label:scaleFont(.5)
label:centerFont()
local errors = {}
if type(q.zoom) ~= "string" and q.zoom ~= "" then
table.insert(errors, "Missing 'zoom' fiels for queston! Should be a path to an image.")
end
if type(q.minzoom) ~= "string" and q.minzoom ~= "" then
table.insert(errors, "Missing 'minzoom' field for question! Should be a path to an image.")
end
if type(q.fullimage) ~= "string" and q.fullimage ~= "" then
table.insert(errors, "Missing 'fullimage' field for question! Should be a path to an image.")
end
local zooms = {q.zoom, q.minzoom, q.fullimage}
print(q.fullimage)
local index = 1
local mul = 1
imageHolder = frame:newImageLabel(q.zoom)
imageHolder:setAspectSize(imageHolder.imageWidth,imageHolder.imageHeight)
imageHolder:centerX(true)
imageHolder:centerY(true)
imageHolder:setDualDim(0,0,imageHolder.imageWidth,imageHolder.imageHeight)
imageHolder:OnEnter(function(self)
self:setShader(gui.SHADERS.glow)
end)
imageHolder:OnExit(function(self)
self:setShader()
end)
imageHolder:shaderTime(true)
imageHolder:OnReleased(function()
index = index + 1
print(index)
if index == 2 then
mul = .5
elseif index == 3 then
mul = .25
else
index = 3
end
imageHolder:setImage(zooms[index])
end)
local correct = window:newTextButton("Correct",0,-200,0,100,0,1,.5)
correct.color = color.new("#52b11b")
local wrong = window:newTextButton("Wrong",0,-200,0,100,.5,1,.5)
wrong.color = color.new("#bd2626")
local skip = window:newTextButton("Skip",0,-100,0,100,.25,1,.5)
skip.color = color.new("#5d5d5d")
window.apply({
scaleFont={.7},
centerFont={},
align=window.ALIGN_CENTER,
OnReleased=function(self)
if self.text == "Skip" then
callback()
return
end
callback(self.text == "Correct", mul)
end,
},correct,wrong,skip)
end
local function update(dt) -- time in seconds that has passed since
_,_,w,h = imageHolder.parent:getAbsolutes()
local x,y,w,h = imageHolder:GetSizeAdjustedToAspectRatio(w,h)
imageHolder:setDualDim(w,h,x,y)
end
return {
index = index,
update = update
}
-245
View File
@@ -1,245 +0,0 @@
-- local gui = require("gui")
-- local color = require("gui.core.color")
-- local transition = require("gui.core.transitions")
-- local multi = require("multi"):init()
-- local timer = transition.glide(3.5,1.5,5)
-- local function startTimer(opt)
-- local default = {
-- duration = 30,
-- autoText = true,
-- autoColor = true,
-- autoCleanup = true,
-- startColor = color.green,
-- textColor = color.black,
-- warnColor = color.yellow,
-- timeColor = color.red,
-- finegrained = false,
-- visibility = 1
-- }
-- if type(opt) == "number" then
-- local d = opt
-- opt = default
-- opt.duration = d
-- elseif type(opt) ~= "table" then
-- opt = default
-- elseif type(opt) == "table" then
-- for i,v in pairs(opt) do
-- default[i] = v
-- end
-- opt = default
-- end
-- local timeRemaining = opt.duration or 30
-- transition.glide:SetFPS(60)
-- local handle = timer(3.5, 1.5, timeRemaining)
-- local tpie = gui:newFrame():makeArc("pie",-200, 0, 100,1 ,0 ,0 ,1.5*math.pi, 3.5*math.pi, 360)
-- local tlabel = tpie:newTextLabel("")
-- tlabel.textColor = opt.textColor
-- tlabel.align = gui.ALIGN_CENTER
-- tlabel:fullFrame()
-- tlabel.visibility = 0
-- tpie.color = opt.startColor
-- tpie.visibility = opt.visibility
-- local tm, num
-- local func = function(p,t)
-- if num ~= timeRemaining-math.floor(t) then
-- num = timeRemaining-math.floor(t)
-- end
-- if opt.autoColor then
-- tpie.color = opt.startColor
-- if num <= timeRemaining/3 then
-- tpie.color = opt.timeColor
-- elseif num <= timeRemaining/2 then
-- tpie.color = opt.warnColor
-- end
-- end
-- tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, p * math.pi, 360)
-- if opt.autoText then
-- tlabel.text = num
-- tlabel:fitFont(nil, nil, {scale=1/2})
-- tlabel:centerFont()
-- end
-- if num == 0 then
-- thread:newThread("Pie Timer",function()
-- thread.yield()
-- if opt.autoText then
-- tlabel.text = "Time"
-- tlabel:fitFont(nil, nil, {scale=1/2})
-- tlabel:centerFont()
-- end
-- tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5 * math.pi, 3.5 * math.pi, 360)
-- tm.OnStop:Fire(tm)
-- if opt.autoCleanup then
-- tm:Cleanup()
-- end
-- tm.OnTime:Destroy()
-- tm.OnStop:Destroy()
-- end)
-- end
-- return tm, num, t
-- end
-- tm = {
-- Duration = timeRemaining,
-- Cleanup = function() handle:Kill() tpie:destroy() tm.Cleanup = function() end end,
-- SetText = function(str) tlabel.text = str end,
-- SetColor = function(c) tpie.color = c end,
-- OnStop = multi:newConnection()
-- }
-- if opt.finegrained then
-- tm.OnTime = func % handle.OnStep
-- else
-- tm.OnTime = function(self, sec, t) return math.floor(t) == t, self, sec end / (func % handle.OnStep)
-- end
-- return tm
-- end
-- return {
-- startTimer = startTimer
-- }
local gui = require("gui")
local color = require("gui.core.color")
local multi, thread = require("multi"):init()
local function startTimer(opt)
local default = {
duration = 30,
autoText = true,
autoColor = true,
autoCleanup = false,
startColor = color.green,
textColor = color.black,
warnColor = color.yellow,
timeColor = color.red,
finegrained = false,
visibility = 1
}
if type(opt) == "number" then
local d = opt
opt = default
opt.duration = d
elseif type(opt) ~= "table" then
opt = default
else
for i, v in pairs(opt) do
default[i] = v
end
opt = default
end
local timeRemaining = opt.duration or 30
local tpie = gui:newFrame():makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360)
local tlabel = tpie:newTextLabel("")
tlabel.textColor = opt.textColor
tlabel.align = gui.ALIGN_CENTER
tlabel:fullFrame()
tlabel.visibility = 0
tpie.color = opt.startColor
tpie.visibility = opt.visibility
local tm
local stopped = false
local onTimeConn = multi:newConnection()
local onStopConn = multi:newConnection()
local function cleanup()
if stopped then return end
stopped = true
if onTimeConn and not onTimeConn.destroyed then
onTimeConn:Destroy()
end
if not tpie.destroyed then
tpie:destroy()
end
end
local timerThread = thread:newThread("Pie Timer", function()
local startTime = love.timer.getTime()
local lastSecond = timeRemaining
while not stopped do
thread.sleep(1/60)
local now = love.timer.getTime()
local elapsed = now - startTime
local t = math.min(elapsed, timeRemaining)
local num = timeRemaining - math.floor(t)
local p = 3.5 - (t / timeRemaining) * 2
if opt.autoColor then
tpie.color = opt.startColor
if num <= timeRemaining / 3 then
tpie.color = opt.timeColor
elseif num <= timeRemaining / 2 then
tpie.color = opt.warnColor
end
end
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, p*math.pi, 360)
if opt.autoText then
tlabel.text = num
tlabel:fitFont(nil, nil, {scale=1/2})
tlabel:centerFont()
end
if opt.finegrained then
onTimeConn:Fire(tm, num, t)
elseif num < lastSecond then
-- crossed a second boundary
lastSecond = num
onTimeConn:Fire(tm, num, t)
end
if elapsed >= timeRemaining then
break
end
end
-- Timer finished
if stopped then return end
if opt.autoText then
tlabel.text = "Time"
tlabel:fitFont(nil, nil, {scale=1/2})
tlabel:centerFont()
end
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360)
local onStop = onStopConn
cleanup()
onStop:Fire(tm)
end)
tm = {
Duration = timeRemaining,
OnTime = onTimeConn,
OnStop = onStopConn,
SetText = function(str) tlabel.text = str end,
SetColor = function(c) tpie.color = c end,
Cleanup = function()
timerThread:Kill()
cleanup()
end,
}
return tm
end
return {
startTimer = startTimer
}
View File
+40 -33
View File
@@ -1,34 +1,41 @@
local yaml = require("yaml")
local loader = {}
loader.__index = loader
function loader:new(path_or_file)
local c = {}
setmetatable(c, loader)
c.source = path_or_file
local file = love.filesystem.read(path_or_file .. "/index.yml")
if not file then
error("Unable to load file: ".. path_or_file .."/index.yml")
end
c.index = yaml.parse(file)
for i,v in pairs(c.index.categories) do
local link = love.filesystem.read(path_or_file .. "/" .. v.name .. ".yml")
if not link then
print("Error! Cannot find file: " .. path_or_file .."/" .. v.name .. ".yml")
else
local category = yaml.parse(link)
c.index.categories[i].questions = category.questions
end
end
return c
end
function loader:getCategories()
return self.index.categories
end
function loader:getSettings()
return self.index.settings
end
local yaml = require("utils/yaml")
local loader = {}
loader.__index = loader
local loaderLog = log:child("loader")
function loader:new(path_or_file)
local c = {}
setmetatable(c, loader)
c.source = path_or_file
local file = love.filesystem.read(path_or_file .. "/index.yml")
if not file then
loaderLog:error("Unable to load file: %s/index.yml", path_or_file)
end
c.index = yaml.parse(file)
for i,v in pairs(c.index.categories) do
local link = love.filesystem.read(path_or_file .. "/" .. v.name .. ".yml")
if not link then
loaderLog:error("Error! Cannot find file: %s/%s.yml", path_or_file,v.name)
else
local category = yaml.parse(link)
c.index.categories[i].questions = category.questions
c.index.categories[i].global = category.global or {}
c.index.categories[i].cosmetics = category.cosmetics or {}
end
end
return c
end
function loader:getCategories()
return self.index.categories
end
function loader:getSettings()
return self.index.settings
end
return loader
+300
View File
@@ -0,0 +1,300 @@
--- logger.lua — A feature-rich logging library for Lua
-- Supports log levels, multiple handlers, formatting, and filtering.
--
-- Usage:
-- local Logger = require("logger")
-- local log = Logger.new("MyApp")
-- log:info("Server started on port %d", 8080)
-- log:warn("Disk usage at %d%%", 92)
-- log:error("Connection refused: %s", err)
local Logger = {}
Logger.__index = Logger
-- ── Level definitions ──────────────────────────────────────────────────────
Logger.LEVELS = {
TRACE = 1,
DEBUG = 2,
INFO = 3,
WARN = 4,
ERROR = 5,
FATAL = 6,
OFF = 7, -- disable all output
}
-- Reverse map: number → name
local LEVEL_NAMES = {}
for name, val in pairs(Logger.LEVELS) do
LEVEL_NAMES[val] = name
end
-- ANSI colour codes (used by the ConsoleHandler when colours are enabled)
local COLOURS = {
[1] = "\27[36m", -- TRACE cyan
[2] = "\27[34m", -- DEBUG blue
[3] = "\27[32m", -- INFO green
[4] = "\27[33m", -- WARN yellow
[5] = "\27[31m", -- ERROR red
[6] = "\27[35m", -- FATAL magenta
RESET = "\27[0m",
}
-- ── Utility helpers ────────────────────────────────────────────────────────
--- Returns an ISO-8601-style timestamp: "2025-06-08 14:32:01"
local function timestamp()
return os.date("%Y-%m-%d %H:%M:%S")
end
--- Safe string formatting — falls back gracefully when fmt is not a string
local function safe_format(fmt, ...)
if select("#", ...) == 0 then
return tostring(fmt)
end
local ok, result = pcall(string.format, tostring(fmt), ...)
return ok and result or (tostring(fmt) .. " " .. table.concat({...}, " "))
end
--- Pads or truncates a string to a fixed width
local function pad(s, width)
s = tostring(s)
if #s >= width then return s:sub(1, width) end
return s .. string.rep(" ", width - #s)
end
-- ── Built-in Handlers ──────────────────────────────────────────────────────
--- ConsoleHandler — writes to stdout (or stderr for ERROR+)
-- Options:
-- colours (bool, default true) — enable ANSI colours
-- stderr (bool, default false) — always write to stderr
local function ConsoleHandler(opts)
opts = opts or {}
local use_colour = (opts.colours ~= false)
local force_stderr = opts.stderr or false
return function(record)
local line = record.formatted
if use_colour then
local c = COLOURS[record.level] or ""
line = c .. line .. COLOURS.RESET
end
local dest = (force_stderr or record.level >= Logger.LEVELS.ERROR) and io.stderr or io.stdout
dest:write(line .. "\n")
dest:flush()
end
end
--- FileHandler — appends log records to a file
-- Options:
-- path (string, required) — path to the log file
-- max_size (number, optional) — rotate when file exceeds this many bytes
-- max_files(number, optional) — keep this many rotated files (default 5)
local function FileHandler(opts)
assert(opts and opts.path, "FileHandler requires opts.path")
local path = opts.path
local max_size = opts.max_size -- nil = never rotate
local max_files = opts.max_files or 5
local file
local function open_file()
file = assert(io.open(path, "a"), "Cannot open log file: " .. path)
end
local function rotate()
if file then file:close(); file = nil end
-- shift old files: .4 → .5, .3 → .4, …, .1 → .2, base → .1
for i = max_files - 1, 1, -1 do
os.rename(path .. "." .. i, path .. "." .. (i + 1))
end
os.rename(path, path .. ".1")
open_file()
end
open_file()
return function(record)
if not file then open_file() end
file:write(record.formatted .. "\n")
file:flush()
if max_size then
local pos = file:seek("cur")
if pos and pos >= max_size then rotate() end
end
end
end
--- MemoryHandler — stores records in a Lua table (useful for testing)
-- Returns handler, get_records() function
local function MemoryHandler(opts)
opts = opts or {}
local limit = opts.limit or 10000
local records = {}
local handler = function(record)
if #records >= limit then table.remove(records, 1) end
table.insert(records, record)
end
local function get_records() return records end
local function clear() records = {} end
return handler, get_records, clear
end
-- Expose built-ins
Logger.handlers = {
ConsoleHandler = ConsoleHandler,
FileHandler = FileHandler,
MemoryHandler = MemoryHandler,
}
-- ── Formatter ─────────────────────────────────────────────────────────────
-- Tokens in the format string:
-- {time} — timestamp
-- {level} — padded level name (5 chars)
-- {name} — logger name
-- {message} — the log message
local DEFAULT_FORMAT = "[{time}] [{level}] [{name}] {message}"
local function escape_replacement(s)
-- In Lua's gsub, '%' in the replacement string is special; escape it.
return (s:gsub("%%", "%%%%"))
end
local function apply_format(fmt, record)
return (fmt
:gsub("{time}", escape_replacement(record.time))
:gsub("{level}", escape_replacement(pad(record.level_name, 5)))
:gsub("{name}", escape_replacement(record.name))
:gsub("{message}", escape_replacement(record.message)))
end
-- ── Logger constructor ─────────────────────────────────────────────────────
--- Create a new logger instance.
-- @param name string — logger name shown in output
-- @param opts table — optional configuration:
-- level (number) — minimum level, default INFO
-- format (string) — format string, default DEFAULT_FORMAT
-- handlers (table) — list of handler functions; defaults to ConsoleHandler
function Logger.new(name, opts)
opts = opts or {}
local self = setmetatable({}, Logger)
self.name = name or "root"
self.level = opts.level or Logger.LEVELS.INFO
self.format = opts.format or DEFAULT_FORMAT
self._handlers = opts.handlers or { ConsoleHandler() }
self._filters = {} -- list of predicate functions
return self
end
-- ── Configuration API ──────────────────────────────────────────────────────
--- Set the minimum log level (Logger.LEVELS.*)
function Logger:set_level(level)
assert(type(level) == "number", "level must be a number from Logger.LEVELS")
self.level = level
return self
end
--- Add a handler function
function Logger:add_handler(handler)
table.insert(self._handlers, handler)
return self
end
--- Remove all handlers and replace with the given ones
function Logger:set_handlers(handlers)
self._handlers = handlers
return self
end
--- Add a filter predicate — a function(record) → bool
-- If ANY filter returns false the record is dropped.
function Logger:add_filter(fn)
table.insert(self._filters, fn)
return self
end
--- Set the output format string
function Logger:set_format(fmt)
self.format = fmt
return self
end
-- ── Core emit logic ────────────────────────────────────────────────────────
function Logger:_emit(level, fmt, ...)
if level < self.level then return end
local record = {
time = timestamp(),
level = level,
level_name = LEVEL_NAMES[level] or ("LVL" .. level),
name = self.name,
message = safe_format(fmt, ...),
}
record.formatted = apply_format(self.format, record)
-- run filters
for _, filter in ipairs(self._filters) do
if not filter(record) then return end
end
-- dispatch to handlers
for _, handler in ipairs(self._handlers) do
local ok, err = pcall(handler, record)
if not ok then
io.stderr:write("[logger] handler error: " .. tostring(err) .. "\n")
end
end
end
-- ── Convenience level methods ──────────────────────────────────────────────
function Logger:trace(fmt, ...) self:_emit(Logger.LEVELS.TRACE, fmt, ...) end
function Logger:debug(fmt, ...) self:_emit(Logger.LEVELS.DEBUG, fmt, ...) end
function Logger:info (fmt, ...) self:_emit(Logger.LEVELS.INFO, fmt, ...) end
function Logger:warn (fmt, ...) self:_emit(Logger.LEVELS.WARN, fmt, ...) end
function Logger:error(fmt, ...) self:_emit(Logger.LEVELS.ERROR, fmt, ...) end
function Logger:fatal(fmt, ...) self:_emit(Logger.LEVELS.FATAL, fmt, ...) end
--- Log at a given numeric level
function Logger:log(level, fmt, ...)
self:_emit(level, fmt, ...)
end
--- Returns true if the given level would be emitted
function Logger:is_enabled(level)
return level >= self.level
end
-- ── Child loggers ──────────────────────────────────────────────────────────
--- Create a child logger that inherits handlers/format but has its own name.
function Logger:child(name, opts)
opts = opts or {}
return Logger.new(self.name .. "." .. name, {
level = opts.level or self.level,
format = opts.format or self.format,
handlers = opts.handlers or self._handlers,
})
end
-- ── Module-level root logger ───────────────────────────────────────────────
Logger._root = Logger.new("root")
--- Get / lazily create a named logger backed by the root's handlers.
local _registry = {}
function Logger.get(name)
if not _registry[name] then
_registry[name] = Logger._root:child(name)
end
return _registry[name]
end
return Logger
+222
View File
@@ -0,0 +1,222 @@
--- love_file_handler.lua — LÖVE2D file handler for logger.lua
--
-- Uses love.filesystem to write logs into LÖVE's save directory, which works
-- correctly on every platform LÖVE supports (Windows, macOS, Linux, Android).
--
-- Requires:
-- logger.lua in the same directory (or on package.path)
-- LÖVE2D 11.x+ (love.filesystem must be available)
--
-- Usage inside main.lua / conf.lua:
--
-- local Logger = require("logger")
-- local LoveHandler = require("love_file_handler")
--
-- local log = Logger.new("game", {
-- handlers = {
-- Logger.handlers.ConsoleHandler(), -- print() fallback
-- LoveHandler.new("logs/game.log"), -- love.filesystem
-- },
-- })
--
-- function love.load()
-- log:info("Game started — LÖVE %s", love.getVersion())
-- end
-- ── Sanity check ──────────────────────────────────────────────────────────
assert(love and love.filesystem,
"love_file_handler requires the LÖVE2D runtime (love.filesystem)")
-- ── Module table ──────────────────────────────────────────────────────────
local LoveHandler = {}
-- ── Helpers ───────────────────────────────────────────────────────────────
--- Ensure every directory component in `path` exists.
-- love.filesystem.createDirectory is recursive on LÖVE 11+.
local function ensure_dir(path)
local dir = path:match("^(.*)/[^/]+$")
if dir and dir ~= "" then
love.filesystem.createDirectory(dir)
end
end
--- Return current file size in bytes, or 0 on error / missing file.
local function file_size(path)
local info = love.filesystem.getInfo(path)
return info and info.size or 0
end
--- Shift rotated files: .4→.5, .3→.4, …, base→.1
local function rotate(path, max_files)
for i = max_files - 1, 1, -1 do
local src = path .. "." .. i
local dest = path .. "." .. (i + 1)
if love.filesystem.getInfo(src) then
-- love.filesystem has no rename; copy + delete
local data = love.filesystem.read(src)
if data then
love.filesystem.write(dest, data)
love.filesystem.remove(src)
end
end
end
-- move base → .1
if love.filesystem.getInfo(path) then
local data = love.filesystem.read(path)
if data then
love.filesystem.write(path .. ".1", data)
love.filesystem.remove(path)
end
end
end
-- ── Constructor ───────────────────────────────────────────────────────────
--- Create a new LÖVE2D file handler.
--
-- @param path string Path inside the LÖVE save directory.
-- Subdirectories are created automatically.
-- Default: "logs/game.log"
--
-- @param opts table Optional settings:
-- max_size (number) Rotate when the log file reaches this many bytes.
-- Default: 1 MB (1 048 576). Set to 0 to disable.
-- max_files (number) How many rotated files to keep. Default: 5.
-- buffered (bool) Accumulate lines in memory and flush in batches.
-- Reduces I/O overhead on mobile / web exports.
-- Default: false.
-- flush_size (number) Lines to buffer before auto-flushing (buffered mode).
-- Default: 20.
-- on_error (func) Called with (err_string) on any I/O error.
-- Default: prints a warning to love.graphics console.
--
-- @return handler, control_table
-- handler — function accepted by Logger:add_handler()
-- control — table with :flush(), :close(), :path(), :size() methods
function LoveHandler.new(path, opts)
path = path or "logs/game.log"
opts = opts or {}
local max_size = (opts.max_size == nil) and (1024 * 1024) or opts.max_size
local max_files = opts.max_files or 5
local buffered = opts.buffered or false
local flush_size = opts.flush_size or 20
local on_error = opts.on_error or function(err)
if love.graphics and love.graphics.print then
print("[love_file_handler] I/O error: " .. tostring(err))
end
end
-- State
local buffer = {} -- pending lines (buffered mode)
local closed = false
ensure_dir(path)
-- ── Internal write ────────────────────────────────────────────────────
local function raw_write(lines)
if #lines == 0 then return end
local text = table.concat(lines, "\n") .. "\n"
-- Rotate if needed
if max_size and max_size > 0 and file_size(path) + #text >= max_size then
rotate(path, max_files)
end
-- Append
local ok, err = love.filesystem.append(path, text)
if not ok then
on_error(tostring(err))
end
end
-- ── Public control table ──────────────────────────────────────────────
local control = {}
--- Flush buffered lines to disk immediately.
function control:flush()
if #buffer > 0 then
raw_write(buffer)
buffer = {}
end
end
--- Close the handler (flushes first). After close() the handler is a no-op.
function control:close()
self:flush()
closed = true
end
--- Full path as seen by love.filesystem.
function control:path()
return path
end
--- Real filesystem path to the save directory file (for external tools).
function control:real_path()
local save_dir = love.filesystem.getSaveDirectory()
return save_dir .. "/" .. path
end
--- Current log file size in bytes.
function control:size()
return file_size(path)
end
--- List rotated files that exist (e.g. {"logs/game.log.1", ...})
function control:rotated_files()
local result = {}
for i = 1, max_files do
local p = path .. "." .. i
if love.filesystem.getInfo(p) then
table.insert(result, p)
end
end
return result
end
--- Read the entire current log file as a string (nil on error).
function control:read()
self:flush()
return love.filesystem.read(path)
end
-- ── Handler function ──────────────────────────────────────────────────
local function handler(record)
if closed then return end
if buffered then
table.insert(buffer, record.formatted)
if #buffer >= flush_size then
raw_write(buffer)
buffer = {}
end
else
raw_write({ record.formatted })
end
end
return handler, control
end
-- ── Convenience: hook love.quit to flush ──────────────────────────────────
--- Wrap love.quit so a control table's flush() is called automatically.
-- Call once per handler after Logger.new(); safe to call multiple times.
--
-- @param control The control table returned by LoveHandler.new()
function LoveHandler.auto_flush_on_quit(control)
local original_quit = love.quit or function() end
love.quit = function()
control:flush()
return original_quit()
end
end
return LoveHandler
+134
View File
@@ -0,0 +1,134 @@
local gui = require("gui")
local color = require("gui.core.color")
local multi, thread = require("multi"):init()
local function startTimer(opt)
local default = {
duration = 30,
autoText = true,
autoColor = true,
autoCleanup = false,
startColor = color.green,
textColor = color.black,
warnColor = color.yellow,
timeColor = color.red,
finegrained = false,
visibility = 1
}
if type(opt) == "number" then
local d = opt
opt = default
opt.duration = d
elseif type(opt) ~= "table" then
opt = default
else
for i, v in pairs(opt) do
default[i] = v
end
opt = default
end
local timeRemaining = opt.duration or 30
local tpie = gui:newFrame():makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360)
local tlabel = tpie:newTextLabel("")
tlabel.textColor = opt.textColor
tlabel.align = gui.ALIGN_CENTER
tlabel:fullFrame()
tlabel.visibility = 0
tlabel:centerFont()
tlabel:scaleFont(.5)
tpie.color = opt.startColor
tpie.visibility = opt.visibility
local tm
local stopped = false
local onTimeConn = multi:newConnection()
local onStopConn = multi:newConnection()
local function cleanup()
if stopped then return end
stopped = true
if onTimeConn and not onTimeConn.destroyed then
onTimeConn:Destroy()
end
if not tpie.destroyed then
tpie:destroy()
end
end
local timerThread = thread:newThread("Pie Timer", function()
local startTime = love.timer.getTime()
local lastSecond = timeRemaining
while not stopped do
thread.sleep(1/60)
local now = love.timer.getTime()
local elapsed = now - startTime
local t = math.min(elapsed, timeRemaining)
local num = timeRemaining - math.floor(t)
local p = 3.5 - (t / timeRemaining) * 2
if opt.autoColor then
tpie.color = opt.startColor
if num <= timeRemaining / 3 then
tpie.color = opt.timeColor
elseif num <= timeRemaining / 2 then
tpie.color = opt.warnColor
end
end
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, p*math.pi, 360)
if opt.autoText then
tlabel.text = num
end
if opt.finegrained then
onTimeConn:Fire(tm, num, t)
elseif num < lastSecond then
-- crossed a second boundary
lastSecond = num
onTimeConn:Fire(tm, num, t)
end
if elapsed >= timeRemaining then
break
end
end
-- Timer finished
if stopped then return end
if opt.autoText then
tlabel.text = "Time"
tlabel:fitFont(nil, nil, {scale=1/2})
tlabel:centerFont()
end
tpie:makeArc("pie", -200, 0, 100, 1, 0, 0, 1.5*math.pi, 3.5*math.pi, 360)
local onStop = onStopConn
cleanup()
onStop:Fire(tm)
end)
tm = {
Duration = timeRemaining,
OnTime = onTimeConn,
OnStop = onStopConn,
SetText = function(str) tlabel.text = str end,
SetColor = function(c) tpie.color = c end,
Cleanup = function()
timerThread:Kill()
cleanup()
end,
}
return tm
end
return {
startTimer = startTimer
}
+1
View File
@@ -0,0 +1 @@
1.0.3
-776
View File
@@ -1,776 +0,0 @@
-------------------------------------------------------------------------------
-- tinyyaml - YAML subset parser
-------------------------------------------------------------------------------
local table = table
local string = string
local schar = string.char
local ssub, gsub = string.sub, string.gsub
local sfind, smatch = string.find, string.match
local tinsert, tremove = table.insert, table.remove
local setmetatable = setmetatable
local pairs = pairs
local type = type
local tonumber = tonumber
local math = math
local getmetatable = getmetatable
local error = error
local UNESCAPES = {
['0'] = "\x00", z = "\x00", N = "\x85",
a = "\x07", b = "\x08", t = "\x09",
n = "\x0a", v = "\x0b", f = "\x0c",
r = "\x0d", e = "\x1b", ['\\'] = '\\',
};
-------------------------------------------------------------------------------
-- utils
local function select(list, pred)
local selected = {}
for i = 0, #list do
local v = list[i]
if v and pred(v, i) then
tinsert(selected, v)
end
end
return selected
end
local function startswith(haystack, needle)
return ssub(haystack, 1, #needle) == needle
end
local function ltrim(str)
return smatch(str, "^%s*(.-)$")
end
local function rtrim(str)
return smatch(str, "^(.-)%s*$")
end
-------------------------------------------------------------------------------
-- Implementation.
--
local class = {__meta={}}
function class.__meta.__call(cls, ...)
local self = setmetatable({}, cls)
if cls.__init then
cls.__init(self, ...)
end
return self
end
function class.def(base, typ, cls)
base = base or class
local mt = {__metatable=base, __index=base}
for k, v in pairs(base.__meta) do mt[k] = v end
cls = setmetatable(cls or {}, mt)
cls.__index = cls
cls.__metatable = cls
cls.__type = typ
cls.__meta = mt
return cls
end
local types = {
null = class:def('null'),
map = class:def('map'),
omap = class:def('omap'),
pairs = class:def('pairs'),
set = class:def('set'),
seq = class:def('seq'),
timestamp = class:def('timestamp'),
}
local Null = types.null
function Null.__tostring() return 'yaml.null' end
function Null.isnull(v)
if v == nil then return true end
if type(v) == 'table' and getmetatable(v) == Null then return true end
return false
end
local null = Null()
function types.timestamp:__init(y, m, d, h, i, s, f, z)
self.year = tonumber(y)
self.month = tonumber(m)
self.day = tonumber(d)
self.hour = tonumber(h or 0)
self.minute = tonumber(i or 0)
self.second = tonumber(s or 0)
if type(f) == 'string' and sfind(f, '^%d+$') then
self.fraction = tonumber(f) * math.pow(10, 3 - #f)
elseif f then
self.fraction = f
else
self.fraction = 0
end
self.timezone = z
end
function types.timestamp:__tostring()
return string.format(
'%04d-%02d-%02dT%02d:%02d:%02d.%03d%s',
self.year, self.month, self.day,
self.hour, self.minute, self.second, self.fraction,
self:gettz())
end
function types.timestamp:gettz()
if not self.timezone then
return ''
end
if self.timezone == 0 then
return 'Z'
end
local sign = self.timezone > 0
local z = sign and self.timezone or -self.timezone
local zh = math.floor(z)
local zi = (z - zh) * 60
return string.format(
'%s%02d:%02d', sign and '+' or '-', zh, zi)
end
local function countindent(line)
local _, j = sfind(line, '^%s+')
if not j then
return 0, line
end
return j, ssub(line, j+1)
end
local function parsestring(line, stopper)
stopper = stopper or ''
local q = ssub(line, 1, 1)
if q == ' ' or q == '\t' then
return parsestring(ssub(line, 2))
end
if q == "'" then
local i = sfind(line, "'", 2, true)
if not i then
return nil, line
end
return ssub(line, 2, i-1), ssub(line, i+1)
end
if q == '"' then
local i, buf = 2, ''
while i < #line do
local c = ssub(line, i, i)
if c == '\\' then
local n = ssub(line, i+1, i+1)
if UNESCAPES[n] ~= nil then
buf = buf..UNESCAPES[n]
elseif n == 'x' then
local h = ssub(i+2,i+3)
if sfind(h, '^[0-9a-fA-F]$') then
buf = buf..schar(tonumber(h, 16))
i = i + 2
else
buf = buf..'x'
end
else
buf = buf..n
end
i = i + 1
elseif c == q then
break
else
buf = buf..c
end
i = i + 1
end
return buf, ssub(line, i+1)
end
if q == '{' or q == '[' then -- flow style
return nil, line
end
if q == '|' or q == '>' then -- block
return nil, line
end
if q == '-' or q == ':' then
if ssub(line, 2, 2) == ' ' or #line == 1 then
return nil, line
end
end
local buf = ''
while #line > 0 do
local c = ssub(line, 1, 1)
if sfind(stopper, c, 1, true) then
break
elseif c == ':' and (ssub(line, 2, 2) == ' ' or #line == 1) then
break
elseif c == '#' and (ssub(buf, #buf, #buf) == ' ') then
break
else
buf = buf..c
end
line = ssub(line, 2)
end
return rtrim(buf), line
end
local function isemptyline(line)
return line == '' or sfind(line, '^%s*$') or sfind(line, '^%s*#')
end
local function equalsline(line, needle)
return startswith(line, needle) and isemptyline(ssub(line, #needle+1))
end
local function checkdupekey(map, key)
if map[key] ~= nil then
-- print("found a duplicate key '"..key.."' in line: "..line)
local suffix = 1
while map[key..'_'..suffix] do
suffix = suffix + 1
end
key = key ..'_'..suffix
end
return key
end
local function parseflowstyle(line, lines)
local stack = {}
while true do
if #line == 0 then
if #lines == 0 then
break
else
line = tremove(lines, 1)
end
end
local c = ssub(line, 1, 1)
if c == '#' then
line = ''
elseif c == ' ' or c == '\t' or c == '\r' or c == '\n' then
line = ssub(line, 2)
elseif c == '{' or c == '[' then
tinsert(stack, {v={},t=c})
line = ssub(line, 2)
elseif c == ':' then
local s = tremove(stack)
tinsert(stack, {v=s.v, t=':'})
line = ssub(line, 2)
elseif c == ',' then
local value = tremove(stack)
if value.t == ':' or value.t == '{' or value.t == '[' then error() end
if stack[#stack].t == ':' then
-- map
local key = tremove(stack)
key.v = checkdupekey(stack[#stack].v, key.v)
stack[#stack].v[key.v] = value.v
elseif stack[#stack].t == '{' then
-- set
stack[#stack].v[value.v] = true
elseif stack[#stack].t == '[' then
-- seq
tinsert(stack[#stack].v, value.v)
end
line = ssub(line, 2)
elseif c == '}' then
if stack[#stack].t == '{' then
if #stack == 1 then break end
stack[#stack].t = '}'
line = ssub(line, 2)
else
line = ','..line
end
elseif c == ']' then
if stack[#stack].t == '[' then
if #stack == 1 then break end
stack[#stack].t = ']'
line = ssub(line, 2)
else
line = ','..line
end
else
local s, rest = parsestring(line, ',{}[]')
if not s then
error('invalid flowstyle line: '..line)
end
tinsert(stack, {v=s, t='s'})
line = rest
end
end
return stack[1].v, line
end
local function parseblockstylestring(line, lines, indent)
if #lines == 0 then
error("failed to find multi-line scalar content")
end
local s = {}
local firstindent = -1
local endline = -1
for i = 1, #lines do
local ln = lines[i]
local idt = countindent(ln)
if idt <= indent then
break
end
if ln == '' then
tinsert(s, '')
else
if firstindent == -1 then
firstindent = idt
elseif idt < firstindent then
break
end
tinsert(s, ssub(ln, firstindent + 1))
end
endline = i
end
local striptrailing = true
local sep = '\n'
local newlineatend = true
if line == '|' then
striptrailing = true
sep = '\n'
newlineatend = true
elseif line == '|+' then
striptrailing = false
sep = '\n'
newlineatend = true
elseif line == '|-' then
striptrailing = true
sep = '\n'
newlineatend = false
elseif line == '>' then
striptrailing = true
sep = ' '
newlineatend = true
elseif line == '>+' then
striptrailing = false
sep = ' '
newlineatend = true
elseif line == '>-' then
striptrailing = true
sep = ' '
newlineatend = false
else
error('invalid blockstyle string:'..line)
end
local eonl = 0
for i = #s, 1, -1 do
if s[i] == '' then
tremove(s, i)
eonl = eonl + 1
end
end
if striptrailing then
eonl = 0
end
if newlineatend then
eonl = eonl + 1
end
for i = endline, 1, -1 do
tremove(lines, i)
end
return table.concat(s, sep)..string.rep('\n', eonl)
end
local function parsetimestamp(line)
local _, p1, y, m, d = sfind(line, '^(%d%d%d%d)%-(%d%d)%-(%d%d)')
if not p1 then
return nil, line
end
if p1 == #line then
return types.timestamp(y, m, d), ''
end
local _, p2, h, i, s = sfind(line, '^[Tt ](%d+):(%d+):(%d+)', p1+1)
if not p2 then
return types.timestamp(y, m, d), ssub(line, p1+1)
end
if p2 == #line then
return types.timestamp(y, m, d, h, i, s), ''
end
local _, p3, f = sfind(line, '^%.(%d+)', p2+1)
if not p3 then
p3 = p2
f = 0
end
local zc = ssub(line, p3+1, p3+1)
local _, p4, zs, z = sfind(line, '^ ?([%+%-])(%d+)', p3+1)
if p4 then
z = tonumber(z)
local _, p5, zi = sfind(line, '^:(%d+)', p4+1)
if p5 then
z = z + tonumber(zi) / 60
end
z = zs == '-' and -tonumber(z) or tonumber(z)
elseif zc == 'Z' then
p4 = p3 + 1
z = 0
else
p4 = p3
z = false
end
return types.timestamp(y, m, d, h, i, s, f, z), ssub(line, p4+1)
end
local function parsescalar(line, lines, indent)
line = ltrim(line)
line = gsub(line, '^%s*#.*$', '') -- comment only -> ''
line = gsub(line, '^%s*', '') -- trim head spaces
if line == '' or line == '~' then
return null
end
local ts, _ = parsetimestamp(line)
if ts then
return ts
end
local s, _ = parsestring(line)
-- startswith quote ... string
-- not startswith quote ... maybe string
if s and (startswith(line, '"') or startswith(line, "'")) then
return s
end
if startswith('!', line) then -- unexpected tagchar
error('unsupported line: '..line)
end
if equalsline(line, '{}') then
return {}
end
if equalsline(line, '[]') then
return {}
end
if startswith(line, '{') or startswith(line, '[') then
return parseflowstyle(line, lines)
end
if startswith(line, '|') or startswith(line, '>') then
return parseblockstylestring(line, lines, indent)
end
-- Regular unquoted string
line = gsub(line, '%s*#.*$', '') -- trim tail comment
local v = line
if v == 'null' or v == 'Null' or v == 'NULL'then
return null
elseif v == 'true' or v == 'True' or v == 'TRUE' then
return true
elseif v == 'false' or v == 'False' or v == 'FALSE' then
return false
elseif v == '.inf' or v == '.Inf' or v == '.INF' then
return math.huge
elseif v == '+.inf' or v == '+.Inf' or v == '+.INF' then
return math.huge
elseif v == '-.inf' or v == '-.Inf' or v == '-.INF' then
return -math.huge
elseif v == '.nan' or v == '.NaN' or v == '.NAN' then
return 0 / 0
elseif sfind(v, '^[%+%-]?[0-9]+$') or sfind(v, '^[%+%-]?[0-9]+%.$')then
return tonumber(v) -- : int
elseif sfind(v, '^[%+%-]?[0-9]+%.[0-9]+$') then
return tonumber(v)
end
return s or v
end
local parsemap; -- : func
local function parseseq(line, lines, indent)
local seq = setmetatable({}, types.seq)
if line ~= '' then
error()
end
while #lines > 0 do
-- Check for a new document
line = lines[1]
if startswith(line, '---') then
while #lines > 0 and not startswith(lines, '---') do
tremove(lines, 1)
end
return seq
end
-- Check the indent level
local level = countindent(line)
if level < indent then
return seq
elseif level > indent then
error("found bad indenting in line: ".. line)
end
local i, j = sfind(line, '%-%s+')
if not i then
i, j = sfind(line, '%-$')
if not i then
return seq
end
end
local rest = ssub(line, j+1)
if sfind(rest, '^[^\'\"%s]*:') then
-- Inline nested hash
local indent2 = j
lines[1] = string.rep(' ', indent2)..rest
tinsert(seq, parsemap('', lines, indent2))
elseif sfind(rest, '^%-%s+') then
-- Inline nested seq
local indent2 = j
lines[1] = string.rep(' ', indent2)..rest
tinsert(seq, parseseq('', lines, indent2))
elseif isemptyline(rest) then
tremove(lines, 1)
if #lines == 0 then
tinsert(seq, null)
return seq
end
if sfind(lines[1], '^%s*%-') then
local nextline = lines[1]
local indent2 = countindent(nextline)
if indent2 == indent then
-- Null seqay entry
tinsert(seq, null)
else
tinsert(seq, parseseq('', lines, indent2))
end
else
-- - # comment
-- key: value
local nextline = lines[1]
local indent2 = countindent(nextline)
tinsert(seq, parsemap('', lines, indent2))
end
elseif rest then
-- Array entry with a value
tremove(lines, 1)
tinsert(seq, parsescalar(rest, lines))
end
end
return seq
end
local function parseset(line, lines, indent)
if not isemptyline(line) then
error('not seq line: '..line)
end
local set = setmetatable({}, types.set)
while #lines > 0 do
-- Check for a new document
line = lines[1]
if startswith(line, '---') then
while #lines > 0 and not startswith(lines, '---') do
tremove(lines, 1)
end
return set
end
-- Check the indent level
local level = countindent(line)
if level < indent then
return set
elseif level > indent then
error("found bad indenting in line: ".. line)
end
local i, j = sfind(line, '%?%s+')
if not i then
i, j = sfind(line, '%?$')
if not i then
return set
end
end
local rest = ssub(line, j+1)
if sfind(rest, '^[^\'\"%s]*:') then
-- Inline nested hash
local indent2 = j
lines[1] = string.rep(' ', indent2)..rest
set[parsemap('', lines, indent2)] = true
elseif sfind(rest, '^%s+$') then
tremove(lines, 1)
if #lines == 0 then
tinsert(set, null)
return set
end
if sfind(lines[1], '^%s*%?') then
local indent2 = countindent(lines[1])
if indent2 == indent then
-- Null array entry
set[null] = true
else
set[parseseq('', lines, indent2)] = true
end
end
elseif rest then
tremove(lines, 1)
set[parsescalar(rest, lines)] = true
else
error("failed to classify line: "..line)
end
end
return set
end
function parsemap(line, lines, indent)
if not isemptyline(line) then
error('not map line: '..line)
end
local map = setmetatable({}, types.map)
while #lines > 0 do
-- Check for a new document
line = lines[1]
if startswith(line, '---') then
while #lines > 0 and not startswith(lines, '---') do
tremove(lines, 1)
end
return map
end
-- Check the indent level
local level, _ = countindent(line)
if level < indent then
return map
elseif level > indent then
error("found bad indenting in line: ".. line)
end
-- Find the key
local key
local s, rest = parsestring(line)
-- Quoted keys
if s and startswith(rest, ':') then
local sc = parsescalar(s, {}, 0)
if sc and type(sc) ~= 'string' then
key = sc
else
key = s
end
line = ssub(rest, 2)
else
error("failed to classify line: "..line)
end
key = checkdupekey(map, key)
line = ltrim(line)
if ssub(line, 1, 1) == '!' then
-- ignore type
local rh = ltrim(ssub(line, 3))
local typename = smatch(rh, '^!?[^%s]+')
line = ltrim(ssub(rh, #typename+1))
end
if not isemptyline(line) then
tremove(lines, 1)
line = ltrim(line)
map[key] = parsescalar(line, lines, indent)
else
-- An indent
tremove(lines, 1)
if #lines == 0 then
map[key] = null
return map;
end
if sfind(lines[1], '^%s*%-') then
local indent2 = countindent(lines[1])
map[key] = parseseq('', lines, indent2)
elseif sfind(lines[1], '^%s*%?') then
local indent2 = countindent(lines[1])
map[key] = parseset('', lines, indent2)
else
local indent2 = countindent(lines[1])
if indent >= indent2 then
-- Null hash entry
map[key] = null
else
map[key] = parsemap('', lines, indent2)
end
end
end
end
return map
end
-- : (list<str>)->dict
local function parsedocuments(lines)
lines = select(lines, function(s) return not isemptyline(s) end)
if sfind(lines[1], '^%%YAML') then tremove(lines, 1) end
local root = {}
local in_document = false
while #lines > 0 do
local line = lines[1]
-- Do we have a document header?
local docright;
if sfind(line, '^%-%-%-') then
-- Handle scalar documents
docright = ssub(line, 4)
tremove(lines, 1)
in_document = true
end
if docright then
if (not sfind(docright, '^%s+$') and
not sfind(docright, '^%s+#')) then
tinsert(root, parsescalar(docright, lines))
end
elseif #lines == 0 or startswith(line, '---') then
-- A naked document
tinsert(root, null)
while #lines > 0 and not sfind(lines[1], '---') do
tremove(lines, 1)
end
in_document = false
-- XXX The final '-+$' is to look for -- which ends up being an
-- error later.
elseif not in_document and #root > 0 then
-- only the first document can be explicit
error('parse error: '..line)
elseif sfind(line, '^%s*%-') then
-- An array at the root
tinsert(root, parseseq('', lines, 0))
elseif sfind(line, '^%s*[^%s]') then
-- A hash at the root
local level = countindent(line)
tinsert(root, parsemap('', lines, level))
else
-- Shouldn't get here. @lines have whitespace-only lines
-- stripped, and previous match is a line with any
-- non-whitespace. So this clause should only be reachable via
-- a perlbug where \s is not symmetric with \S
-- uncoverable statement
error('parse error: '..line)
end
end
if #root > 1 and Null.isnull(root[1]) then
tremove(root, 1)
return root
end
return root
end
--- Parse yaml string into table.
local function parse(source)
local lines = {}
for line in string.gmatch(source .. '\n', '(.-)\r?\n') do
tinsert(lines, line)
end
local docs = parsedocuments(lines)
if #docs == 1 then
return docs[1]
end
return docs
end
return {
version = 0.1,
parse = parse,
}