332 lines
9.8 KiB
Lua
332 lines
9.8 KiB
Lua
|
-- 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'
|
||
|
|
||
|
-- namespace for these functions
|
||
|
live = {}
|
||
|
-- state for these functions
|
||
|
Live = {}
|
||
|
|
||
|
-- a namespace of frameworky callbacks
|
||
|
-- these will be modified live
|
||
|
-- TODO: how to make them discoverable?
|
||
|
on = {}
|
||
|
|
||
|
-- ========= on startup, load the version at head
|
||
|
|
||
|
function live.initialize(arg)
|
||
|
-- version control
|
||
|
Live.Head = 0
|
||
|
Live.Next_version = 1
|
||
|
Live.History = {} -- array of filename roots corresponding to each numeric prefix
|
||
|
Live.Manifest = {} -- mapping from roots to numeric prefixes as of version Live.Head
|
||
|
live.load_files_so_far()
|
||
|
|
||
|
if on.load then on.load() end
|
||
|
end
|
||
|
|
||
|
function live.load_files_so_far()
|
||
|
print('new edits will go to ' .. love.filesystem.getSaveDirectory())
|
||
|
local files = {}
|
||
|
live.append_files_with_numeric_prefix('', files)
|
||
|
table.sort(files)
|
||
|
live.check_integrity(files)
|
||
|
live.append_files_with_numeric_prefix(love.filesystem.getSaveDirectory(), files)
|
||
|
table.sort(files)
|
||
|
live.check_integrity(files)
|
||
|
Live.History = live.load_history(files)
|
||
|
Live.Next_version = #Live.History + 1
|
||
|
local head_string = love.filesystem.read('head')
|
||
|
Live.Head = tonumber(head_string)
|
||
|
if Live.Head > 0 then
|
||
|
Live.Manifest = json.decode(love.filesystem.read(live.versioned_manifest(Live.Head)))
|
||
|
end
|
||
|
live.load_everything_in_manifest()
|
||
|
end
|
||
|
|
||
|
function live.append_files_with_numeric_prefix(dir, files)
|
||
|
for _,file in ipairs(love.filesystem.getDirectoryItems(dir)) do
|
||
|
table.insert(files, file)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function live.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 live.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 live.load_everything_in_manifest()
|
||
|
for k,v in pairs(Live.Manifest) do
|
||
|
if k ~= 'parent' then
|
||
|
local root, index = k, v
|
||
|
local filename = live.versioned_filename(index, root)
|
||
|
local buf = love.filesystem.read(filename)
|
||
|
assert(buf and buf ~= '')
|
||
|
live.eval(buf)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function live.versioned_filename(index, root)
|
||
|
return ('%04d-%s'):format(index, root)
|
||
|
end
|
||
|
|
||
|
function live.versioned_manifest(index)
|
||
|
return ('%04d-manifest'):format(index)
|
||
|
end
|
||
|
|
||
|
-- ========= on each frame, check for messages and alter the app as needed
|
||
|
|
||
|
function live.update(dt)
|
||
|
if Current_time - Live.Previous_read > 0.1 then
|
||
|
local buf = live.receive()
|
||
|
if buf then
|
||
|
live.run(buf)
|
||
|
end
|
||
|
Live.Previous_read = Current_time
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- look for a message from outside, and return nil if there's nothing
|
||
|
function live.receive()
|
||
|
local f = io.open(love.filesystem.getUserDirectory()..'/_love_akkartik_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('<='..color(--[[bold]]1, --[[blue]]4))
|
||
|
print(result)
|
||
|
print(reset_terminal())
|
||
|
-- we can't unlink files, so just clear them
|
||
|
local clear = io.open(love.filesystem.getUserDirectory()..'/_love_akkartik_app', 'w')
|
||
|
clear:close()
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
function live.send(msg)
|
||
|
local f = io.open(love.filesystem.getUserDirectory()..'/_love_akkartik_driver', 'w')
|
||
|
if f == nil then return end
|
||
|
f:write(msg)
|
||
|
f:close()
|
||
|
print('=>'..color(0, --[[green]]2))
|
||
|
print(msg)
|
||
|
print(reset_terminal())
|
||
|
end
|
||
|
|
||
|
-- args:
|
||
|
-- format: 0 for normal, 1 for bold
|
||
|
-- color: 0-15
|
||
|
function color(format, color)
|
||
|
return ('\027[%d;%dm'):format(format, 30+color)
|
||
|
end
|
||
|
|
||
|
function reset_terminal()
|
||
|
return '\027[m'
|
||
|
end
|
||
|
|
||
|
-- define or undefine top-level bindings
|
||
|
function live.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
|
||
|
live.send(json.encode(Live.Manifest))
|
||
|
elseif cmd == 'DELETE' then
|
||
|
local binding = buf:match('^%S+%s+(%S+)')
|
||
|
Live.Manifest[binding] = nil
|
||
|
live.eval(binding..' = nil')
|
||
|
local next_filename = live.versioned_filename(Live.Next_version, binding)
|
||
|
love.filesystem.write(next_filename, '')
|
||
|
table.insert(Live.History, binding)
|
||
|
Live.Manifest.parent = Live.Head
|
||
|
local manifest_filename = live.versioned_manifest(Live.Next_version)
|
||
|
love.filesystem.write(manifest_filename, json.encode(Live.Manifest))
|
||
|
Live.Head = Live.Next_version
|
||
|
love.filesystem.write('head', tostring(Live.Head))
|
||
|
Live.Next_version = Live.Next_version + 1
|
||
|
elseif cmd == 'GET' then
|
||
|
local binding = buf:match('^%S+%s+(%S+)')
|
||
|
live.send(live.get_binding(binding))
|
||
|
-- other commands go here
|
||
|
else
|
||
|
local binding = cmd
|
||
|
local next_filename = live.versioned_filename(Live.Next_version, binding)
|
||
|
love.filesystem.write(next_filename, buf)
|
||
|
table.insert(Live.History, binding)
|
||
|
Live.Manifest[binding] = Live.Next_version
|
||
|
Live.Manifest.parent = Live.Head
|
||
|
local manifest_filename = live.versioned_manifest(Live.Next_version)
|
||
|
love.filesystem.write(manifest_filename, json.encode(Live.Manifest))
|
||
|
Live.Head = Live.Next_version
|
||
|
love.filesystem.write('head', tostring(Live.Head))
|
||
|
Live.Next_version = Live.Next_version + 1
|
||
|
local status, err = live.eval(buf)
|
||
|
if not status then
|
||
|
-- roll back
|
||
|
Live.Head = Live.Manifest.parent
|
||
|
local previous_manifest_filename = live.versioned_manifest(Live.Head)
|
||
|
Live.Manifest = json.decode(love.filesystem.read(previous_manifest_filename))
|
||
|
-- throw an error
|
||
|
live.send('ERROR '..tostring(err))
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function live.get_binding(name)
|
||
|
if Live.Manifest[name] then
|
||
|
return love.filesystem.read(live.versioned_filename(Live.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 live.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
|
||
|
|
||
|
-- ========= on error, pause the app and wait for messages
|
||
|
|
||
|
function live.run()
|
||
|
love.load(love.arg.parseGameArguments(arg), arg)
|
||
|
love.timer.step()
|
||
|
local dt = 0
|
||
|
|
||
|
return function()
|
||
|
local status, result = xpcall(live.try_run, live.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 live.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 live.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
|
||
|
live.send('ERROR '..stack_trace)
|
||
|
local buf
|
||
|
repeat
|
||
|
buf = live.receive()
|
||
|
until buf
|
||
|
if buf == 'QUIT' then
|
||
|
return true
|
||
|
end
|
||
|
live.run(buf)
|
||
|
end
|