-- A general architecture for free-wheeling, live programs:
-- on startup:
-- scan both the app directory and the save directory for files with numeric prefixes
-- from the largest numeric prefix found, obtain a manifest
-- load all files (which must start with a numeric prefix) from the manifest)
--
-- then start drawing frames on screen and reacting to events
--
-- events from keyboard and mouse are handled as the app desires
--
-- on incoming messages to a specific file, however, the app must:
-- save the message's value to a specific, unused numeric prefix
-- execute the value
--
-- if a game encounters an error:
-- find the previous version of the definition in the 'head' numeric prefix
-- decrement 'head'
json = require 'json'
-- a namespace of "frameworky" things in addition to love.*
-- these can't be modified
app = {}
-- a namespace of frameworky callbacks
-- these can be modified live
on = {}
-- ========= on startup, load the version at head
function love.load(args, commandline)
-- version control
app.Head = 0
app.Next_version = 1
app.History = {} -- array of filename roots corresponding to each numeric prefix
app.Manifest = {} -- mapping from roots to numeric prefixes as of version app.Head
app.load_files_so_far()
-- hysteresis in a few places
app.Current_time = 0
app.Previous_read = 0
app.Last_focus_time = 0
app.Last_resize_time = 0
love.window.setTitle('app')
if on.load then on.load() end
end
function app.load_files_so_far()
print('new edits will go to ' .. love.filesystem.getSaveDirectory())
local files = {}
app.append_files_with_numeric_prefix('', files)
table.sort(files)
app.check_integrity(files)
app.append_files_with_numeric_prefix(love.filesystem.getSaveDirectory(), files)
table.sort(files)
app.check_integrity(files)
app.History = app.load_history(files)
app.Next_version = #app.History + 1
local head_string = love.filesystem.read('head')
app.Head = tonumber(head_string)
if app.Head > 0 then
app.Manifest = json.decode(love.filesystem.read(app.versioned_manifest(app.Head)))
end
app.load_everything_in_manifest()
end
function app.append_files_with_numeric_prefix(dir, files)
for _,file in ipairs(love.filesystem.getDirectoryItems(dir)) do
table.insert(files, file)
end
end
function app.check_integrity(files)
local manifest_found, file_found = false, false
local expected_index = 1
for _,file in ipairs(files) do
for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do
-- only runs once
local index = tonumber(numeric_prefix)
-- skip files without numeric prefixes
if index ~= nil then
if index < expected_index then
print(index, expected_index)
end
assert(index >= expected_index)
if index > expected_index then
assert(index == expected_index+1)
assert(manifest_found and file_found)
expected_index = index
manifest_found, file_found = false, false
end
assert(index == expected_index)
if root == 'manifest' then
assert(not manifest_found)
manifest_found = true
else
assert(not file_found)
file_found = true
end
end
end
end
end
function app.load_history(files)
local result = {}
for _,file in ipairs(files) do
for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do
-- only runs once
local index = tonumber(numeric_prefix)
-- skip
if index ~= nil then
if root ~= 'manifest' then
assert(index == #result+1)
table.insert(result, root)
end
end
end
end
return result
end
function app.load_everything_in_manifest()
for k,v in pairs(app.Manifest) do
if k ~= 'parent' then
local root, index = k, v
local filename = app.versioned_filename(index, root)
local buf = love.filesystem.read(filename)
assert(buf and buf ~= '')
app.eval(buf)
end
end
end
function app.versioned_filename(index, root)
return ('%04d-%s'):format(index, root)
end
function app.versioned_manifest(index)
return ('%04d-manifest'):format(index)
end
-- ========= on each frame, check for messages and alter the app as needed
function love.update(dt)
app.Current_time = app.Current_time + dt
if app.Current_time < app.Last_resize_time + 0.1 then
return
end
if app.Current_time - app.Previous_read > 0.1 then
local buf = app.receive_from_driver()
if buf then
app.run(buf)
end
app.Previous_read = app.Current_time
end
if on.update then on.update(dt) end
end
-- look for a message from outside, and return nil if there's nothing
function app.receive_from_driver()
local f = io.open(love.filesystem.getUserDirectory()..'/_love_akkartik_driver_app')
if f == nil then return nil end
local result = f:read('*a')
f:close()
if result == '' then return nil end -- empty file == no message
print('<='..app.color(--[[bold]]1, --[[blue]]4))
print(result)
print(app.reset_terminal())
-- we can't unlink files, so just clear them
local clear = io.open(love.filesystem.getUserDirectory()..'/_love_akkartik_driver_app', 'w')
clear:close()
return result
end
function app.send_to_driver(msg)
local f = io.open(love.filesystem.getUserDirectory()..'/_love_akkartik_app_driver', 'w')
if f == nil then return end
f:write(msg)
f:close()
print('=>'..app.color(0, --[[green]]2))
print(msg)
print(app.reset_terminal())
end
-- args:
-- format: 0 for normal, 1 for bold
-- color: 0-15
function app.color(format, color)
return ('\027[%d;%dm'):format(format, 30+color)
end
function app.reset_terminal()
return '\027[m'
end
-- define or undefine top-level bindings
function app.run(buf)
local cmd = buf:match('^%S+')
assert(cmd)
print('command is '..cmd)
if cmd == 'QUIT' then
love.event.quit(1)
elseif cmd == 'MANIFEST' then
app.send_to_driver(json.encode(app.Manifest))
elseif cmd == 'DELETE' then
local binding = buf:match('^%S+%s+(%S+)')
app.Manifest[binding] = nil
app.eval(binding..' = nil')
local next_filename = app.versioned_filename(app.Next_version, binding)
love.filesystem.write(next_filename, '')
table.insert(app.History, binding)
app.Manifest.parent = app.Head
local manifest_filename = app.versioned_manifest(app.Next_version)
love.filesystem.write(manifest_filename, json.encode(app.Manifest))
app.Head = app.Next_version
love.filesystem.write('head', tostring(app.Head))
app.Next_version = app.Next_version + 1
elseif cmd == 'GET' then
local binding = buf:match('^%S+%s+(%S+)')
app.send_to_driver(app.get_binding(binding))
-- other commands go here
else
local binding = cmd
local next_filename = app.versioned_filename(app.Next_version, binding)
love.filesystem.write(next_filename, buf)
table.insert(app.History, binding)
app.Manifest[binding] = app.Next_version
app.Manifest.parent = app.Head
local manifest_filename = app.versioned_manifest(app.Next_version)
love.filesystem.write(manifest_filename, json.encode(app.Manifest))
app.Head = app.Next_version
love.filesystem.write('head', tostring(app.Head))
app.Next_version = app.Next_version + 1
local status, err = app.eval(buf)
if not status then
-- roll back
app.Head = app.Manifest.parent
local previous_manifest_filename = app.versioned_manifest(app.Head)
app.Manifest = json.decode(love.filesystem.read(previous_manifest_filename))
-- throw an error
app.send_to_driver('ERROR '..tostring(err))
end
end
end
function app.get_binding(name)
if app.Manifest[name] then
return love.filesystem.read(app.versioned_filename(app.Manifest[name], name))
end
end
-- Wrapper for Lua's weird evaluation model.
-- Lua is persnickety about expressions vs statements, so we need to do some
-- extra work to get the result of an evaluation.
-- return values:
-- all well -> true, ...
-- load failed -> nil, error message
-- run failed -> false, error message
function app.eval(buf)
-- We assume a program is either correct with 'return' prefixed xor not.
-- Is this correct? Who knows! But the Lua REPL does this as well.
local f = load('return '..buf, 'REPL')
if f then
return pcall(f)
end
local f, err = load(buf, 'REPL')
if f then
return pcall(f)
else
return nil, err
end
end
-- all other love callbacks can just be delegated to the on.* namespace
-- just easier to remember
-- how to make these discoverable?
function love.draw()
if on.draw then on.draw() end
end
function love.quit()
if on.quit then on.quit() end
end
function love.resize(w,h)
screen = {w=w, h=h}
if on.resize then on.resize(w,h) end
app.Last_resize_time = app.Current_time
end
function love.filedropped()
if on.filedropped then on.filedropped() end
end
function love.focus(in_focus)
if in_focus then
app.Last_focus_time = app.Current_time
end
if on.focus then on.focus(in_focus) end
end
function love.keypressed(key, scancode, isrepeat)
-- ignore events for some time after window in focus (mostly alt-tab)
if app.Current_time < app.Last_focus_time + 0.01 then
return
end
if on.keypressed then on.keypressed(key, scancode, isrepeat) end
end
function love.textinput(t)
-- ignore events for some time after window in focus (mostly alt-tab)
if app.Current_time < app.Last_focus_time + 0.01 then
return
end
if on.textinput then on.textinput(t) end
end
function love.keyreleased(key, scancode, isrepeat)
if on.keyreleased then on.keyreleased(key, scancode, isrepeat) end
end
function love.mousepressed(x,y, mouse_button)
if on.mousepressed then on.mousepressed(x,y, mouse_button) end
end
function love.mousereleased(x,y, mouse_button)
if on.mousereleased then on.mousereleased(x,y, mouse_button) end
end
-- ========= on error, pause the app and wait for messages
function love.run()
love.load(love.arg.parseGameArguments(arg), arg)
love.timer.step()
local dt = 0
return function()
local status, result = xpcall(app.try_run, app.handle_error)
return result
end
end
-- one iteration of the event loop
-- return nil to continue the event loop, non-nil to quit
-- from https://love2d.org/wiki/love.run
function app.try_run()
if love.event then
love.event.pump()
for name, a,b,c,d,e,f in love.event.poll() do
if name == 'quit' then
if not love.quit() then
return a or 0
end
end
love.handlers[name](a,b,c,d,e,f)
end
end
-- update
dt = love.timer.step()
love.update(dt)
-- draw before update to give it a chance to mutate state
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
love.draw()
love.graphics.present()
love.timer.sleep(0.001)
-- returning nil continues the loop
end
-- return nil to continue the event loop, non-nil to quit
function app.handle_error(err)
local msg = tostring(err)
-- draw a pause indicator on screen
love.graphics.setColor(1,0,0)
love.graphics.rectangle('fill', 10,10, 3,10)
love.graphics.rectangle('fill', 16,10, 3,10)
love.graphics.present()
-- print stack trace here just in case we ran the app through a terminal
local stack_trace = debug.traceback('Error: ' .. tostring(msg), --[[stack frame]]2):gsub('\n[^\n]+$', '')
print(stack_trace)
print('Look in the driver for options to investigate further.')
print("(You probably can't close the app window at this point. If you don't have the driver set up, you might need to force-quit.)")
-- send stack trace to driver and wait for a response
app.send_to_driver('ERROR '..stack_trace)
local buf
repeat
buf = app.receive_from_driver()
until buf
if buf == 'QUIT' then
return true
end
app.run(buf)
end
-- some abbreviations
graphics = love.graphics
color = graphics.setColor
line = graphics.line
rect = graphics.rectangle
circ = graphics.circle
ellipse = graphics.ellipse
-- also screen is set in resize above (which seems to also be called during initialization)