diff --git a/async.lua b/async.lua new file mode 100644 index 0000000..78b0a8a --- /dev/null +++ b/async.lua @@ -0,0 +1,69 @@ +-- small async implementation using coroutines to suspend a function awaiting a callback +local promise = {} +promise.__index = promise + +function promise:new(fn) + local obj = setmetatable({ + done_callbacks = {}, + error_callbacks = {}, + executed = false + }, promise) + fn(function(...) + obj.executed = true + for i = 1, #obj.done_callbacks do + obj.done_callbacks[i](...) + end + end, function(...) + obj.executed = true + for i = 1, #obj.error_callbacks do + obj.error_callbacks[i](...) + end + if #obj.error_callbacks == 0 then + print("Error: ", ...) + error("Uncaught error in promise") + end + end) + return obj +end + +function promise:done(callback) + self.done_callbacks[#self.done_callbacks + 1] = callback + return self +end + +function promise:err(callback) + self.error_callback[#self.error_callback + 1] = callback + return self +end + +local async = setmetatable({}, { + __call = function(_, fn) + return function(...) + local args = {...} + return promise:new(function(resolve, reject) + local co = coroutine.create(function() + local ret = {xpcall(fn, reject, unpack(args))} + if ret[1] then + resolve(unpack(ret, 2)) + end + end) + coroutine.resume(co) + end) + end + end +}) + +async.await = function(prom) + local co = coroutine.running() + if not co then + error("cannot await outide of an async function") + end + prom:done(function(...) + coroutine.resume(co, ...) + end) + return coroutine.yield() +end + +async.promise = promise + +return async diff --git a/compat/game192/assets.lua b/compat/game192/assets.lua index db3513e..434b036 100644 --- a/compat/game192/assets.lua +++ b/compat/game192/assets.lua @@ -2,6 +2,11 @@ local log = require("log")(...) local args = require("args") local json = require("extlibs.json.jsonc") local vfs = require("compat.game192.virtual_filesystem") +local threaded_assets, threadify +if not args.headless then + threadify = require("threadify") + threaded_assets = threadify.require("compat.game192.assets") +end local assets = {} local packs = {} @@ -128,21 +133,33 @@ function assets.init(data, persistent_data, audio, config) pack_data.levels[level_json.id] = level_json end end + packs[folder] = pack_data + end +end - -- styles have to be loaded here to draw the preview icons in the level selection - pack_data.styles = {} - for contents, filename in - file_ext_read_iter(pack_data.path .. "Styles", ".json", pack_data.virtual_pack_folder.Styles) - do - local style_json - success, style_json = decode_json(contents, filename) - if success then - pack_data.styles[style_json.id] = style_json - end - end +function assets.preload_styles(pack_data) + if pack_data.style_load_promise then + -- already pending + return pack_data.style_load_promise + end + -- load styles in thread for level preview + pack_data.style_load_promise = threaded_assets.load_styles(pack_data):done(function(styles) + pack_data.styles = styles + end) +end - packs[folder] = pack_data +function assets.load_styles(pack_data) + -- styles have to be loaded here to draw the preview icons in the level selection + pack_data.styles = {} + for contents, filename in + file_ext_read_iter(pack_data.path .. "Styles", ".json", pack_data.virtual_pack_folder.Styles) + do + local success, style_json = decode_json(contents, filename) + if success then + pack_data.styles[style_json.id] = style_json + end end + return pack_data.styles end function assets.get_pack_no_load(folder) @@ -186,6 +203,18 @@ function assets.get_pack(folder) end end + -- styles + if pack_data.style_load_promise and not pack_data.style_load_promise.executed then + -- wait for styles if pending threaded loading not done yet + while not pack_data.style_load_promise.executed do + threadify.update() + love.timer.sleep(0.01) + end + elseif not pack_data.styles then + -- load them synchronously if no threaded loading is pending and styles aren't loaded + assets.load_styles(pack_data) + end + -- events pack_data.events = {} for contents, filename in file_ext_read_iter(folder .. "Events", ".json", pack_data.virtual_pack_folder.Events) do diff --git a/compat/game192/init.lua b/compat/game192/init.lua index edb5abd..237d987 100644 --- a/compat/game192/init.lua +++ b/compat/game192/init.lua @@ -595,6 +595,10 @@ function public.draw_preview(canvas, pack, level) if not pack_data then error("pack with id '" .. pack .. "' not found") end + assets.preload_styles(pack_data) + if pack_data.style_load_promise and not pack_data.style_load_promise.executed then + return pack_data.style_load_promise + end local level_data = pack_data.levels[level] if level_data == nil then error("Level with id '" .. level .. "' not found") diff --git a/game_handler/init.lua b/game_handler/init.lua index 3bc4c5e..cd4e4da 100644 --- a/game_handler/init.lua +++ b/game_handler/init.lua @@ -288,7 +288,7 @@ end ---@param level string function game_handler.draw_preview(canvas, game_version, pack, level) if games[game_version].draw_preview then - games[game_version].draw_preview(canvas, pack, level) + return games[game_version].draw_preview(canvas, pack, level) end end diff --git a/main.lua b/main.lua index cb9bb69..38a6856 100644 --- a/main.lua +++ b/main.lua @@ -165,6 +165,7 @@ function love.run() end local game_handler = require("game_handler") + local threadify = require("threadify") global_config.init(config, game_handler.profile) game_handler.init(config) local ui = require("ui") @@ -189,12 +190,16 @@ function love.run() love.event.pump() for name, a, b, c, d, e, f in love.event.poll() do if name == "quit" then + threadify.stop() return a or 0 + elseif name == "threaderror" then + error("Error in thread: " .. b) end game_handler.process_event(name, a, b, c, d, e, f) ui.process_event(name, a, b, c, d, e, f) end + threadify.update() ui.update(love.timer.getDelta()) -- ensures tickrate on its own diff --git a/threadify.lua b/threadify.lua new file mode 100644 index 0000000..308757c --- /dev/null +++ b/threadify.lua @@ -0,0 +1,96 @@ +local log = require("log")("threadify") +local modname, is_thread = ... + +if is_thread then + local api = require(modname) + local run = true + while run do + local cmd = love.thread.getChannel(modname .. "_cmd"):demand() + local call_id = cmd[1] + local out_channel = love.thread.getChannel(modname .. "_out") + xpcall(function() + local fn = api[cmd[2]] + out_channel:push({ call_id, true, fn(unpack(cmd, 3)) }) + end, function(err) + out_channel:push({ call_id, false, "Failed to call '" .. modname .. "." .. cmd[2] .. "'", err }) + end) + end +else + local async = require("async") + local threads = {} + local thread_names = {} + local threadify = {} + + function threadify.require(require_string) + if not threads[require_string] then + local thread_table = { + thread = love.thread.newThread("threadify.lua"), + resolvers = {}, + rejecters = {}, + free_indices = {}, + } + thread_table.thread:start(require_string, true) + threads[require_string] = thread_table + thread_names[#thread_names + 1] = require_string + end + local thread = threads[require_string] + local interface = {} + return setmetatable(interface, { + __index = function(_, key) + return function(...) + local msg = {-1, key, ...} + return async.promise:new(function(resolve, reject) + local index = 0 + if #thread.free_indices == 0 then + index = #thread.resolvers + 1 + else + local last_index = #thread.free_indices + index = thread.free_indices[last_index] + thread.free_indices[last_index] = nil + end + msg[1] = index + love.thread.getChannel(require_string .. "_cmd"):push(msg) + thread.resolvers[index] = resolve + thread.rejecters[index] = reject + end) + end + end + }) + end + + function threadify.update() + for i = 1, #thread_names do + local require_string = thread_names[i] + local thread = threads[require_string] + local result = love.thread.getChannel(require_string .. "_out"):pop() + if result then + if result[2] then + thread.resolvers[result[1]](unpack(result, 3)) + else + log(result[3]) + thread.rejecters[result[1]](result[4]) + end + thread.resolvers[result[1]] = nil + thread.rejecters[result[1]] = nil + thread.free_indices[#thread.free_indices + 1] = result[1] + end + end + end + + function threadify.stop() + for require_string, thread_table in pairs(threads) do + local thread = thread_table.thread + if thread:isRunning() then + -- effectively kills the thread (sending stop doesn't work sometimes and when it does it would still cause unresponsiveness on closing) + thread:release() + else + local err = thread:getError() + if err then + log("Error in '" .. require_string .. "' thread: " .. err) + end + end + end + end + + return threadify +end diff --git a/ui/elements/level_preview.lua b/ui/elements/level_preview.lua index 0819287..3c03ef7 100644 --- a/ui/elements/level_preview.lua +++ b/ui/elements/level_preview.lua @@ -1,5 +1,6 @@ local element = require("ui.elements.element") local game_handler = require("game_handler") +local signal = require("ui.anim.signal") local level_preview = {} level_preview.__index = setmetatable(level_preview, { __index = element, @@ -20,11 +21,20 @@ local function redraw(self) love.graphics.setCanvas(canvas) love.graphics.origin() love.graphics.clear(0, 0, 0, 1) - love.graphics.translate(canvas:getWidth() / 2, canvas:getHeight() / 2) + local half_size = canvas:getWidth() / 2 + love.graphics.translate(half_size, half_size) love.graphics.scale(GAME_SCALE, GAME_SCALE) love.graphics.setColor(1, 1, 1, 1) - game_handler.draw_preview(canvas, self.game_version, self.pack, self.level) + local promise = game_handler.draw_preview(canvas, self.game_version, self.pack, self.level) love.graphics.setCanvas() + if promise then + -- styles not loaded yet + promise:done(function() + -- redraw once styles are loaded + redraw(self) + end) + return + end self.image = love.graphics.newImage(canvas:newImageData()) end @@ -35,6 +45,9 @@ function level_preview:new(game_version, pack, level, options) pack = pack, level = level, last_scale = 1, + angle = signal.new_waveform(1, function(x) + return x * 2 * math.pi + end), }, level_preview), options ) @@ -54,11 +67,30 @@ function level_preview:draw() redraw(self) end self.last_scale = self.scale - love.graphics.draw( - self.image, - self.bounds[1] + self.padding * self.scale, - self.bounds[2] + self.padding * self.scale - ) + local pos_x, pos_y = self.bounds[1] + self.padding * self.scale, self.bounds[2] + self.padding * self.scale + if self.image then + love.graphics.draw( + self.image, + self.bounds[1] + self.padding * self.scale, + self.bounds[2] + self.padding * self.scale + ) + else + -- loading circle (TODO: replace hardcoded colors and line width maybe) + local half_size = SIZE * self.scale / 2 + local center_x, center_y = pos_x + half_size, pos_y + half_size + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setLineWidth(self.scale * 5) + love.graphics.circle("line", center_x, center_y, half_size / 2, 100) + love.graphics.setColor(0, 0, 0, 1) + local half_sector_size = math.pi / 4 + local radius = half_size + love.graphics.polygon( + "fill", + center_x, center_y, + center_x + math.cos(self.angle() - half_sector_size) * radius, center_y + math.sin(self.angle() - half_sector_size) * radius, + center_x + math.cos(self.angle() + half_sector_size) * radius, center_y + math.sin(self.angle() + half_sector_size) * radius + ) + end end return level_preview