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()