carousel.love/live.lua

332 lines
9.8 KiB
Lua
Raw Normal View History

2022-11-27 22:06:11 +00:00
-- 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