diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..ace4fd9 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,3 @@ +!baton.lua +!lovebird.lua +!lurker.lua diff --git a/src/baton.lua b/src/baton.lua new file mode 100644 index 0000000..4cc1206 --- /dev/null +++ b/src/baton.lua @@ -0,0 +1,374 @@ +local baton = { + _VERSION = 'Baton v1.0.2', + _DESCRIPTION = 'Input library for LÖVE.', + _URL = 'https://github.com/tesselode/baton', + _LICENSE = [[ + MIT License + + Copyright (c) 2020 Andrew Minnich + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ]] +} + +-- string parsing functions -- + +-- splits a source definition into type and value +-- example: 'button:a' -> 'button', 'a' +local function parseSource(source) + return source:match '(.+):(.+)' +end + +-- splits an axis value into axis and direction +-- example: 'leftx-' -> 'leftx', '-' +local function parseAxis(value) + return value:match '(.+)([%+%-])' +end + +-- splits a joystick hat value into hat number and direction +-- example: '2rd' -> '2', 'rd' +local function parseHat(value) + return value:match '(%d)(.+)' +end + +--[[ + -- source functions -- + + each source function checks the state of one type of input + and returns a value from 0 to 1. for binary controls, such + as keyboard keys and gamepad buttons, they return 1 if the + input is held down and 0 if not. for analog controls, such + as "leftx+" (the left analog stick held to the right), they + return a number from 0 to 1. + + source functions are split into keyboard/mouse functions + and joystick/gamepad functions. baton treats these two + categories slightly differently. +]] + +local sourceFunction = {keyboardMouse = {}, joystick = {}} + +-- checks whether a keyboard key is down or not +function sourceFunction.keyboardMouse.key(key) + return love.keyboard.isDown(key) and 1 or 0 +end + +-- checks whether a keyboard key is down or not, +-- but it takes a scancode as an input +function sourceFunction.keyboardMouse.sc(sc) + return love.keyboard.isScancodeDown(sc) and 1 or 0 +end + +-- checks whether a mouse buttons is down or not. +-- note that baton doesn't detect mouse movement, just the buttons +function sourceFunction.keyboardMouse.mouse(button) + return love.mouse.isDown(tonumber(button)) and 1 or 0 +end + +-- checks the position of a joystick axis +function sourceFunction.joystick.axis(joystick, value) + local axis, direction = parseAxis(value) + -- "a and b or c" is ok here because b will never be boolean + value = tonumber(axis) and joystick:getAxis(tonumber(axis)) + or joystick:getGamepadAxis(axis) + if direction == '-' then value = -value end + return value > 0 and value or 0 +end + +-- checks whether a joystick button is held down or not +-- can take a number or a GamepadButton string +function sourceFunction.joystick.button(joystick, button) + -- i'm intentionally not using the "a and b or c" idiom here + -- because joystick.isDown returns a boolean + if tonumber(button) then + return joystick:isDown(tonumber(button)) and 1 or 0 + else + return joystick:isGamepadDown(button) and 1 or 0 + end +end + +-- checks the direction of a joystick hat +function sourceFunction.joystick.hat(joystick, value) + local hat, direction = parseHat(value) + return joystick:getHat(hat) == direction and 1 or 0 +end + +--[[ + -- player class -- + + the player object takes a configuration table and handles input + accordingly. it's called a "player" because it makes sense to use + multiple of these for each player in a multiplayer game, but + you can use separate player objects to organize inputs + however you want. +]] + +local Player = {} +Player.__index = Player + +-- internal functions -- + +-- sets the player's config to a user-defined config table +-- and sets some defaults if they're not already defined +function Player:_loadConfig(config) + if not config then + error('No config table provided', 4) + end + if not config.controls then + error('No controls specified', 4) + end + config.pairs = config.pairs or {} + config.deadzone = config.deadzone or .5 + config.squareDeadzone = config.squareDeadzone or false + self.config = config +end + +-- initializes a control object for each control defined in the config +function Player:_initControls() + self._controls = {} + for controlName, sources in pairs(self.config.controls) do + self._controls[controlName] = { + sources = sources, + rawValue = 0, + value = 0, + down = false, + downPrevious = false, + pressed = false, + released = false, + } + end +end + +-- initializes an axis pair object for each axis pair defined in the config +function Player:_initPairs() + self._pairs = {} + for pairName, controls in pairs(self.config.pairs) do + self._pairs[pairName] = { + controls = controls, + rawX = 0, + rawY = 0, + x = 0, + y = 0, + down = false, + downPrevious = false, + pressed = false, + released = false, + } + end +end + +function Player:_init(config) + self:_loadConfig(config) + self:_initControls() + self:_initPairs() + self._activeDevice = 'none' +end + +--[[ + detects the active device (keyboard/mouse or joystick). + if the keyboard or mouse is currently being used, joystick + inputs will be ignored. this is to prevent slight axis movements + from adding errant inputs when someone's using the keyboard. + + the active device is saved to player._activeDevice, which is then + used throughout the rest of the update loop to check only + keyboard or joystick inputs. +]] +function Player:_setActiveDevice() + -- if the joystick is unset, then we should make sure _activeDevice + -- isn't "joy" anymore, otherwise there will be an error later + -- when we try to query a joystick that isn't there + if self._activeDevice == 'joy' and not self.config.joystick then + self._activeDevice = 'none' + end + for _, control in pairs(self._controls) do + for _, source in ipairs(control.sources) do + local type, value = parseSource(source) + if sourceFunction.keyboardMouse[type] then + if sourceFunction.keyboardMouse[type](value) > self.config.deadzone then + self._activeDevice = 'kbm' + return + end + elseif self.config.joystick and sourceFunction.joystick[type] then + if sourceFunction.joystick[type](self.config.joystick, value) > self.config.deadzone then + self._activeDevice = 'joy' + end + end + end + end +end + +--[[ + gets the value of a control by running the appropriate source functions + for all of its sources. does not apply deadzone. +]] +function Player:_getControlRawValue(control) + local rawValue = 0 + for _, source in ipairs(control.sources) do + local type, value = parseSource(source) + if sourceFunction.keyboardMouse[type] and self._activeDevice == 'kbm' then + if sourceFunction.keyboardMouse[type](value) == 1 then + return 1 + end + elseif sourceFunction.joystick[type] and self._activeDevice == 'joy' then + rawValue = rawValue + sourceFunction.joystick[type](self.config.joystick, value) + if rawValue >= 1 then + return 1 + end + end + end + return rawValue +end + +--[[ + updates each control in a player. saves the value with and without deadzone + and the down/pressed/released state. +]] +function Player:_updateControls() + for _, control in pairs(self._controls) do + control.rawValue = self:_getControlRawValue(control) + control.value = control.rawValue >= self.config.deadzone and control.rawValue or 0 + control.downPrevious = control.down + control.down = control.value > 0 + control.pressed = control.down and not control.downPrevious + control.released = control.downPrevious and not control.down + end +end + +--[[ + updates each axis pair in a player. saves the value with and without deadzone + and the down/pressed/released state. +]] +function Player:_updatePairs() + for _, pair in pairs(self._pairs) do + -- get raw x and y + local l = self._controls[pair.controls[1]].rawValue + local r = self._controls[pair.controls[2]].rawValue + local u = self._controls[pair.controls[3]].rawValue + local d = self._controls[pair.controls[4]].rawValue + pair.rawX, pair.rawY = r - l, d - u + + -- limit to 1 + local len = math.sqrt(pair.rawX^2 + pair.rawY^2) + if len > 1 then + pair.rawX, pair.rawY = pair.rawX / len, pair.rawY / len + end + + -- deadzone + if self.config.squareDeadzone then + pair.x = math.abs(pair.rawX) > self.config.deadzone and pair.rawX or 0 + pair.y = math.abs(pair.rawY) > self.config.deadzone and pair.rawY or 0 + else + pair.x = len > self.config.deadzone and pair.rawX or 0 + pair.y = len > self.config.deadzone and pair.rawY or 0 + end + + -- down/pressed/released + pair.downPrevious = pair.down + pair.down = pair.x ~= 0 or pair.y ~= 0 + pair.pressed = pair.down and not pair.downPrevious + pair.released = pair.downPrevious and not pair.down + end +end + +-- public API -- + +-- checks for changes in inputs +function Player:update() + self:_setActiveDevice() + self:_updateControls() + self:_updatePairs() +end + +-- gets the value of a control or axis pair without deadzone applied +function Player:getRaw(name) + if self._pairs[name] then + return self._pairs[name].rawX, self._pairs[name].rawY + elseif self._controls[name] then + return self._controls[name].rawValue + else + error('No control with name "' .. name .. '" defined', 3) + end +end + +-- gets the value of a control or axis pair with deadzone applied +function Player:get(name) + if self._pairs[name] then + return self._pairs[name].x, self._pairs[name].y + elseif self._controls[name] then + return self._controls[name].value + else + error('No control with name "' .. name .. '" defined', 3) + end +end + +-- gets whether a control or axis pair is "held down" +function Player:down(name) + if self._pairs[name] then + return self._pairs[name].down + elseif self._controls[name] then + return self._controls[name].down + else + error('No control with name "' .. name .. '" defined', 3) + end +end + +-- gets whether a control or axis pair was pressed this frame +function Player:pressed(name) + if self._pairs[name] then + return self._pairs[name].pressed + elseif self._controls[name] then + return self._controls[name].pressed + else + error('No control with name "' .. name .. '" defined', 3) + end +end + +-- gets whether a control or axis pair was released this frame +function Player:released(name) + if self._pairs[name] then + return self._pairs[name].released + elseif self._controls[name] then + return self._controls[name].released + else + error('No control with name "' .. name .. '" defined', 3) + end +end + +--[[ + gets the currently active device (either "kbm", "joy", or "none"). + this is useful for displaying instructional text. you may have + a menu that says "press ENTER to confirm" or "press A to confirm" + depending on whether the player is using their keyboard or gamepad. + this function allows you to detect which they used most recently. +]] +function Player:getActiveDevice() + return self._activeDevice +end + +-- main functions -- + +-- creates a new player with the user-provided config table +function baton.new(config) + local player = setmetatable({}, Player) + player:_init(config) + return player +end + +return baton diff --git a/src/lovebird.lua b/src/lovebird.lua new file mode 100644 index 0000000..8b296eb --- /dev/null +++ b/src/lovebird.lua @@ -0,0 +1,737 @@ +-- +-- lovebird +-- +-- Copyright (c) 2017 rxi +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. +-- + +local socket = require "socket" + +local lovebird = { _version = "0.4.3" } + +lovebird.loadstring = loadstring or load +lovebird.inited = false +lovebird.host = "*" +lovebird.buffer = "" +lovebird.lines = {} +lovebird.connections = {} +lovebird.pages = {} + +lovebird.wrapprint = true +lovebird.timestamp = true +lovebird.allowhtml = false +lovebird.echoinput = true +lovebird.port = 8000 +lovebird.whitelist = { "127.0.0.1" } +lovebird.maxlines = 200 +lovebird.updateinterval = .5 + + +lovebird.pages["index"] = [[ + + + + + + + + lovebird + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + +]] + + +lovebird.pages["buffer"] = [[ ]] + + +lovebird.pages["env.json"] = [[ + +{ + "valid": true, + "path": "", + "vars": [ + + { + "key": "", + "value": , + "type": "", + }, + + ] +} +]] + + + +function lovebird.init() + -- Init server + lovebird.server = assert(socket.bind(lovebird.host, lovebird.port)) + lovebird.addr, lovebird.port = lovebird.server:getsockname() + lovebird.server:settimeout(0) + -- Wrap print + lovebird.origprint = print + if lovebird.wrapprint then + local oldprint = print + print = function(...) + oldprint(...) + lovebird.print(...) + end + end + -- Compile page templates + for k, page in pairs(lovebird.pages) do + lovebird.pages[k] = lovebird.template(page, "lovebird, req", + "pages." .. k) + end + lovebird.inited = true +end + + +function lovebird.template(str, params, chunkname) + params = params and ("," .. params) or "" + local f = function(x) return string.format(" echo(%q)", x) end + str = ("?>"..str.."(.-)<%?lua", f) + str = "local echo " .. params .. " = ..." .. str + local fn = assert(lovebird.loadstring(str, chunkname)) + return function(...) + local output = {} + local echo = function(str) table.insert(output, str) end + fn(echo, ...) + return table.concat(lovebird.map(output, tostring)) + end +end + + +function lovebird.map(t, fn) + local res = {} + for k, v in pairs(t) do res[k] = fn(v) end + return res +end + + +function lovebird.trace(...) + local str = "[lovebird] " .. table.concat(lovebird.map({...}, tostring), " ") + print(str) + if not lovebird.wrapprint then lovebird.print(str) end +end + + +function lovebird.unescape(str) + local f = function(x) return string.char(tonumber("0x"..x)) end + return (str:gsub("%+", " "):gsub("%%(..)", f)) +end + + +function lovebird.parseurl(url) + local res = {} + res.path, res.search = url:match("/([^%?]*)%??(.*)") + res.query = {} + for k, v in res.search:gmatch("([^&^?]-)=([^&^#]*)") do + res.query[k] = lovebird.unescape(v) + end + return res +end + + +local htmlescapemap = { + ["<"] = "<", + ["&"] = "&", + ['"'] = """, + ["'"] = "'", +} + +function lovebird.htmlescape(str) + return ( str:gsub("[<&\"']", htmlescapemap) ) +end + + +function lovebird.truncate(str, len) + if #str <= len then + return str + end + return str:sub(1, len - 3) .. "..." +end + + +function lovebird.compare(a, b) + local na, nb = tonumber(a), tonumber(b) + if na then + if nb then return na < nb end + return false + elseif nb then + return true + end + return tostring(a) < tostring(b) +end + + +function lovebird.checkwhitelist(addr) + if lovebird.whitelist == nil then return true end + for _, a in pairs(lovebird.whitelist) do + local ptn = "^" .. a:gsub("%.", "%%."):gsub("%*", "%%d*") .. "$" + if addr:match(ptn) then return true end + end + return false +end + + +function lovebird.clear() + lovebird.lines = {} + lovebird.buffer = "" +end + + +function lovebird.pushline(line) + line.time = os.time() + line.count = 1 + table.insert(lovebird.lines, line) + if #lovebird.lines > lovebird.maxlines then + table.remove(lovebird.lines, 1) + end + lovebird.recalcbuffer() +end + + +function lovebird.recalcbuffer() + local function doline(line) + local str = line.str + if not lovebird.allowhtml then + str = lovebird.htmlescape(line.str):gsub("\n", "
") + end + if line.type == "input" then + str = '' .. str .. '' + else + if line.type == "error" then + str = '! ' .. str + str = '' .. str .. '' + end + if line.count > 1 then + str = '' .. line.count .. ' ' .. str + end + if lovebird.timestamp then + str = os.date('%H:%M:%S ', line.time) .. + str + end + end + return str + end + lovebird.buffer = table.concat(lovebird.map(lovebird.lines, doline), "
") +end + + +function lovebird.print(...) + local t = {} + for i = 1, select("#", ...) do + table.insert(t, tostring(select(i, ...))) + end + local str = table.concat(t, " ") + local last = lovebird.lines[#lovebird.lines] + if last and str == last.str then + -- Update last line if this line is a duplicate of it + last.time = os.time() + last.count = last.count + 1 + lovebird.recalcbuffer() + else + -- Create new line + lovebird.pushline({ type = "output", str = str }) + end +end + + +function lovebird.onerror(err) + lovebird.pushline({ type = "error", str = err }) + if lovebird.wrapprint then + lovebird.origprint("[lovebird] ERROR: " .. err) + end +end + + +function lovebird.onrequest(req, client) + local page = req.parsedurl.path + page = page ~= "" and page or "index" + -- Handle "page not found" + if not lovebird.pages[page] then + return "HTTP/1.1 404\r\nContent-Length: 8\r\n\r\nBad page" + end + -- Handle page + local str + xpcall(function() + local data = lovebird.pages[page](lovebird, req) + local contenttype = "text/html" + if string.match(page, "%.json$") then + contenttype = "application/json" + end + str = "HTTP/1.1 200 OK\r\n" .. + "Content-Type: " .. contenttype .. "\r\n" .. + "Content-Length: " .. #data .. "\r\n" .. + "\r\n" .. data + end, lovebird.onerror) + return str +end + + +function lovebird.receive(client, pattern) + while 1 do + local data, msg = client:receive(pattern) + if not data then + if msg == "timeout" then + -- Wait for more data + coroutine.yield(true) + else + -- Disconnected -- yielding nil means we're done + coroutine.yield(nil) + end + else + return data + end + end +end + + +function lovebird.send(client, data) + local idx = 1 + while idx < #data do + local res, msg = client:send(data, idx) + if not res and msg == "closed" then + -- Handle disconnect + coroutine.yield(nil) + else + idx = idx + res + coroutine.yield(true) + end + end +end + + +function lovebird.onconnect(client) + -- Create request table + local requestptn = "(%S*)%s*(%S*)%s*(%S*)" + local req = {} + req.socket = client + req.addr, req.port = client:getsockname() + req.request = lovebird.receive(client, "*l") + req.method, req.url, req.proto = req.request:match(requestptn) + req.headers = {} + while 1 do + local line, msg = lovebird.receive(client, "*l") + if not line or #line == 0 then break end + local k, v = line:match("(.-):%s*(.*)$") + req.headers[k] = v + end + if req.headers["Content-Length"] then + req.body = lovebird.receive(client, req.headers["Content-Length"]) + end + -- Parse body + req.parsedbody = {} + if req.body then + for k, v in req.body:gmatch("([^&]-)=([^&^#]*)") do + req.parsedbody[k] = lovebird.unescape(v) + end + end + -- Parse request line's url + req.parsedurl = lovebird.parseurl(req.url) + -- Handle request; get data to send and send + local data = lovebird.onrequest(req) + lovebird.send(client, data) + -- Clear up + client:close() +end + + +function lovebird.update() + if not lovebird.inited then lovebird.init() end + -- Handle new connections + while 1 do + -- Accept new connections + local client = lovebird.server:accept() + if not client then break end + client:settimeout(0) + local addr = client:getsockname() + if lovebird.checkwhitelist(addr) then + -- Connection okay -- create and add coroutine to set + local conn = coroutine.wrap(function() + xpcall(function() lovebird.onconnect(client) end, function() end) + end) + lovebird.connections[conn] = true + else + -- Reject connection not on whitelist + lovebird.trace("got non-whitelisted connection attempt: ", addr) + client:close() + end + end + -- Handle existing connections + for conn in pairs(lovebird.connections) do + -- Resume coroutine, remove if it has finished + local status = conn() + if status == nil then + lovebird.connections[conn] = nil + end + end +end + + +return lovebird diff --git a/src/lurker.lua b/src/lurker.lua new file mode 100644 index 0000000..9200ee7 --- /dev/null +++ b/src/lurker.lua @@ -0,0 +1,269 @@ +-- +-- lurker +-- +-- Copyright (c) 2018 rxi +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. +-- + +-- Assumes lume is in the same directory as this file if it does not exist +-- as a global +local lume = rawget(_G, "lume") or require((...):gsub("[^/.\\]+$", "lume")) + +local lurker = { _version = "1.0.1" } + + +local dir = love.filesystem.enumerate or love.filesystem.getDirectoryItems +local time = love.timer.getTime or os.time + +local function isdir(path) + local info = love.filesystem.getInfo(path) + return info.type == "directory" +end + +local function lastmodified(path) + local info = love.filesystem.getInfo(path, "file") + return info.modtime +end + +local lovecallbacknames = { + "update", + "load", + "draw", + "mousepressed", + "mousereleased", + "keypressed", + "keyreleased", + "focus", + "quit", +} + + +function lurker.init() + lurker.print("Initing lurker") + lurker.path = "." + lurker.preswap = function() end + lurker.postswap = function() end + lurker.interval = .5 + lurker.protected = true + lurker.quiet = false + lurker.lastscan = 0 + lurker.lasterrorfile = nil + lurker.files = {} + lurker.funcwrappers = {} + lurker.lovefuncs = {} + lurker.state = "init" + lume.each(lurker.getchanged(), lurker.resetfile) + return lurker +end + + +function lurker.print(...) + print("[lurker] " .. lume.format(...)) +end + + +function lurker.listdir(path, recursive, skipdotfiles) + path = (path == ".") and "" or path + local function fullpath(x) return path .. "/" .. x end + local t = {} + for _, f in pairs(lume.map(dir(path), fullpath)) do + if not skipdotfiles or not f:match("/%.[^/]*$") then + if recursive and isdir(f) then + t = lume.concat(t, lurker.listdir(f, true, true)) + else + table.insert(t, lume.trim(f, "/")) + end + end + end + return t +end + + +function lurker.initwrappers() + for _, v in pairs(lovecallbacknames) do + lurker.funcwrappers[v] = function(...) + local args = {...} + xpcall(function() + return lurker.lovefuncs[v] and lurker.lovefuncs[v](unpack(args)) + end, lurker.onerror) + end + lurker.lovefuncs[v] = love[v] + end + lurker.updatewrappers() +end + + +function lurker.updatewrappers() + for _, v in pairs(lovecallbacknames) do + if love[v] ~= lurker.funcwrappers[v] then + lurker.lovefuncs[v] = love[v] + love[v] = lurker.funcwrappers[v] + end + end +end + + +function lurker.onerror(e, nostacktrace) + lurker.print("An error occurred; switching to error state") + lurker.state = "error" + + -- Release mouse + local setgrab = love.mouse.setGrab or love.mouse.setGrabbed + setgrab(false) + + -- Set up callbacks + for _, v in pairs(lovecallbacknames) do + love[v] = function() end + end + + love.update = lurker.update + + love.keypressed = function(k) + if k == "escape" then + lurker.print("Exiting...") + love.event.quit() + end + end + + local stacktrace = nostacktrace and "" or + lume.trim((debug.traceback("", 2):gsub("\t", ""))) + local msg = lume.format("{1}\n\n{2}", {e, stacktrace}) + local colors = { + { lume.color("#1e1e2c", 256) }, + { lume.color("#f0a3a3", 256) }, + { lume.color("#92b5b0", 256) }, + { lume.color("#66666a", 256) }, + { lume.color("#cdcdcd", 256) }, + } + love.graphics.reset() + love.graphics.setFont(love.graphics.newFont(12)) + + love.draw = function() + local pad = 25 + local width = love.graphics.getWidth() + + local function drawhr(pos, color1, color2) + local animpos = lume.smooth(pad, width - pad - 8, lume.pingpong(time())) + if color1 then love.graphics.setColor(color1) end + love.graphics.rectangle("fill", pad, pos, width - pad*2, 1) + if color2 then love.graphics.setColor(color2) end + love.graphics.rectangle("fill", animpos, pos, 8, 1) + end + + local function drawtext(str, x, y, color, limit) + love.graphics.setColor(color) + love.graphics[limit and "printf" or "print"](str, x, y, limit) + end + + love.graphics.setBackgroundColor(colors[1]) + love.graphics.clear() + + drawtext("An error has occurred", pad, pad, colors[2]) + drawtext("lurker", width - love.graphics.getFont():getWidth("lurker") - + pad, pad, colors[4]) + drawhr(pad + 32, colors[4], colors[5]) + drawtext("If you fix the problem and update the file the program will " .. + "resume", pad, pad + 46, colors[3]) + drawhr(pad + 72, colors[4], colors[5]) + drawtext(msg, pad, pad + 90, colors[5], width - pad * 2) + + love.graphics.reset() + end +end + + +function lurker.exitinitstate() + lurker.state = "normal" + if lurker.protected then + lurker.initwrappers() + end +end + + +function lurker.exiterrorstate() + lurker.state = "normal" + for _, v in pairs(lovecallbacknames) do + love[v] = lurker.funcwrappers[v] + end +end + + +function lurker.update() + if lurker.state == "init" then + lurker.exitinitstate() + end + local diff = time() - lurker.lastscan + if diff > lurker.interval then + lurker.lastscan = lurker.lastscan + diff + local changed = lurker.scan() + if #changed > 0 and lurker.lasterrorfile then + local f = lurker.lasterrorfile + lurker.lasterrorfile = nil + lurker.hotswapfile(f) + end + end +end + + +function lurker.getchanged() + local function fn(f) + return f:match("%.lua$") and lurker.files[f] ~= lastmodified(f) + end + return lume.filter(lurker.listdir(lurker.path, true, true), fn) +end + + +function lurker.modname(f) + return (f:gsub("%.lua$", ""):gsub("[/\\]", ".")) +end + + +function lurker.resetfile(f) + lurker.files[f] = lastmodified(f) +end + + +function lurker.hotswapfile(f) + lurker.print("Hotswapping '{1}'...", {f}) + if lurker.state == "error" then + lurker.exiterrorstate() + end + if lurker.preswap(f) then + lurker.print("Hotswap of '{1}' aborted by preswap", {f}) + lurker.resetfile(f) + return + end + local modname = lurker.modname(f) + local t, ok, err = lume.time(lume.hotswap, modname) + if ok then + lurker.print("Swapped '{1}' in {2} secs", {f, t}) + else + lurker.print("Failed to swap '{1}' : {2}", {f, err}) + if not lurker.quiet and lurker.protected then + lurker.lasterrorfile = f + lurker.onerror(err, true) + lurker.resetfile(f) + return + end + end + lurker.resetfile(f) + lurker.postswap(f) + if lurker.protected then + lurker.updatewrappers() + end +end + + +function lurker.scan() + if lurker.state == "init" then + lurker.exitinitstate() + end + local changed = lurker.getchanged() + lume.each(changed, lurker.hotswapfile) + return changed +end + + +return lurker.init()