main.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
-- load files in order
--
-- 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, the app must:
-- determine the definition name from the first word
-- execute the value, returning any errors
-- look up the filename for the definition or define a new filename for it
-- save the message's value to the filename
--
-- if a game encounters a run-time error, send it to the driver and await
-- further instructions. The app will go unresponsive in the meantime, that
-- is expected. To shut it down cleanly, type C-q in the driver.
json = require 'json'
-- available modes: run, error
Mode = 'run'
Error_message = nil
Error_count = 0
-- we'll reuse error mode on load for an initial version check
Supported_versions = {'11.5', '11.4', '11.3', '11.2', '11.1', '11.0', '12.0'} -- put the recommended version first
Version = nil
-- a namespace of "frameworky" things in addition to love.*
-- these can't be modified
app = {}
-- a namespace of frameworky callbacks
-- these will be modified live
on = {}
-- === on startup, load all files with numeric prefix
function love.load(args, commandline)
-- version check
local major, minor = love.getVersion()
Version = major..'.'..minor
if array.find(Supported_versions, Version) == nil then
Mode = 'error'
Error_message = ("This app doesn't support version %s; please use version %s. Press any key to try it with this version anyway."):format(Version, Supported_versions[1])
print(Error_message)
-- continue initializing everything; hopefully we won't have errors during initialization
end
app.freeze_all_existing_definitions()
app.Filenames_to_load = {} -- filenames in order of numeric prefix
app.Filename = {} -- map from definition name to filename (including numeric prefix)
app.Final_prefix = 0
app.load_files_so_far()
-- hysteresis in a few places
Current_time = 0
app.Previous_read = 0
app.Last_focus_time = 0
app.Last_resize_time = 0
love.window.setTitle('app')
if on.initialize then on.initialize() end
end
function app.load_files_so_far()
print('new edits will go to ' .. love.filesystem.getSaveDirectory())
-- if necessary, copy files from repo to save dir
if io.open(love.filesystem.getSaveDirectory()..'/0000-freewheeling-start') == nil then
print('copying all definitions from repo to save dir')
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
local numeric_prefix, root = filename:match('^(%d+)-(.+)')
if numeric_prefix then
local buf = love.filesystem.read(filename)
print('copying', filename)
love.filesystem.write(filename, buf)
end
end
end
-- load files to load in save dir
-- (ignore the repo because we might have deleted definitions)
for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
if io.open(love.filesystem.getSaveDirectory()..'/'..filename) then
local numeric_prefix, root = filename:match('^(%d+)-(.+)')
if numeric_prefix and tonumber(numeric_prefix) > 0 then -- skip 0000
app.Filename[root] = filename
table.insert(app.Filenames_to_load, filename)
app.Final_prefix = math.max(app.Final_prefix, tonumber(numeric_prefix))
end
end
end
table.sort(app.Filenames_to_load)
-- load files from save dir
for _,filename in ipairs(app.Filenames_to_load) do
--? print('loading', filename)
local buf = love.filesystem.read(filename)
assert(buf and buf ~= '')
local _, definition_name = filename:match('^(%d+)-(.+)')
local status, err = app.eval(buf, definition_name)
if not status then
error(err)
end
end
end
APP = 'fw_app'
-- === on each frame, check for messages and alter the app as needed
function love.update(dt)
Current_time = Current_time + dt
if Current_time < app.Last_resize_time + 0.1 then
return
end
-- listen for commands in both 'error' and 'run' modes
if Current_time - app.Previous_read > 0.1 then
local buf = app.receive()
if buf then
local possibly_mutated = app.run(buf)
if possibly_mutated then
Mode = 'run'
if on.code_change then on.code_change() end
end
end
app.Previous_read = Current_time
end
if Mode == 'run' then
if on.update then on.update(dt) end
end
end
-- look for a message from outside, and return nil if there's nothing
function app.receive()
local f = io.open(love.filesystem.getAppdataDirectory()..'/_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())
os.remove(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
return result
end
function app.send(msg)
local f = io.open(love.filesystem.getAppdataDirectory()..'/_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
function app.send_run_time_error(msg)
local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver_run_time_error', 'w')
if f == nil then return end
f:write(msg)
f:close()
print('=>'..app.color(0, --[[red]]1))
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
-- returns true if we might have mutated the app, by either creating or deleting a definition
function app.run(buf)
local cmd = buf:match('^%s*(%S+)')
assert(cmd)
print('checking for command: '..cmd)
if cmd == 'QUIT' then
love.event.quit(1)
elseif cmd == 'RESTART' then
restart()
elseif cmd == 'MANIFEST' then
app.Filename[APP] = love.filesystem.getIdentity()
app.send(json.encode(app.Filename))
elseif cmd == 'DELETE' then
local definition_name = buf:match('^%s*%S+%s+(%S+)')
if app.Frozen_definitions[definition_name] then
app.send('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be deleted.')
return
end
if app.Filename[definition_name] then
local index = array.find(app.Filenames_to_load, app.Filename[definition_name])
table.remove(app.Filenames_to_load, index)
app.eval(definition_name..' = nil') -- ignore errors which will likely be from keywords like `function = nil`
love.filesystem.remove(app.Filename[definition_name])
app.Filename[definition_name] = nil
end
app.send('{}')
return true
elseif cmd == 'GET' then
local definition_name = buf:match('^%s*%S+%s+(%S+)')
local val, _ = app.get_binding(definition_name)
if val then
app.send(val)
else
app.send('ERROR no such value')
end
elseif cmd == 'GET*' then
-- batch version of GET
local result = {}
for definition_name in buf:gmatch('%s+(%S+)') do
print(definition_name)
local val, _ = app.get_binding(definition_name)
if val then
table.insert(result, val)
end
end
local delimiter = '\n==fw: definition boundary==\n'
app.send(table.concat(result, delimiter)..delimiter) -- send a final delimiter to simplify the driver's task
elseif cmd == 'DEFAULT_MAP' then
local contents = love.filesystem.read('default_map')
if contents == nil then contents = '{}' end
app.send(contents)
-- other commands go here
else
local definition_name = buf:gsub('%-%-[^\n]*', ''):match('^%s*(%S+)')
if definition_name == nil then
-- contents are all Lua comments; we don't currently have a plan for them
app.send('ERROR empty definition')
return
end
print('definition name is '..definition_name)
if app.Frozen_definitions[definition_name] then
app.send('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be safely edited live.')
return
end
local status, err = app.eval(buf, definition_name)
if not status then
-- throw an error
app.send('ERROR '..tostring(err))
return
end
-- eval succeeded without errors; persist the definition
local filename = app.Filename[definition_name]
if filename == nil then
app.Final_prefix = app.Final_prefix+1
filename = ('%04d-%s'):format(app.Final_prefix, definition_name)
table.insert(app.Filenames_to_load, filename)
app.Filename[definition_name] = filename
end
love.filesystem.write(filename, buf)
app.send('{}')
return true
end
end
-- Everything that exists before we start loading the live files is frozen and
-- can't be edited live.
function app.freeze_all_existing_definitions()
app.Frozen_definitions = {on=true} -- special case for version 1
local done = {}
done[app.Frozen_definitions]=true
app.freeze_all_existing_definitions_in(_G, {}, done)
end
function app.freeze_all_existing_definitions_in(tab, scopes, done)
-- track duplicates to avoid cycles like _G._G, _G._G._G, etc.
if done[tab] then return end
done[tab] = true
for name,binding in pairs(tab) do
local full_name = app.full_name(scopes, name)
--? print(full_name)
app.Frozen_definitions[full_name] = true
if type(binding) == 'table' and full_name ~= 'package' then -- var 'package' contains copies of all modules, but not the best name; rely on people to not modify package.loaded.io.open, etc.
table.insert(scopes, name)
app.freeze_all_existing_definitions_in(binding, scopes, done)
table.remove(scopes)
end
end
end
function app.full_name(scopes, name)
local ns = table.concat(scopes, '.')
if #ns == 0 then return name end
return ns..'.'..name
end
function app.get_binding(name)
if app.Filename[name] then
return love.filesystem.read(app.Filename[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 (pcall) failed -> false, error message
function app.eval(buf, name)
-- 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, name or 'REPL')
if f then
return pcall(f)
end
local f, err = load(buf, name or '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 Mode == 'error' then
love.graphics.setColor(0,0,1)
love.graphics.rectangle('fill', 30, 30, 1000, 80)
love.graphics.setColor(1,1,1)
love.graphics.printf(Error_message, 40, 40, 600)
return
end
if on.draw then on.draw() end
end
function love.quit()
if Mode == 'error' then return end
if on.quit then on.quit() end
end
function restart()
if on.quit then on.quit() end
if on.initialize then on.initialize() end
end
function love.resize(w,h)
if Mode == 'error' then return end
screen = {w=w, h=h}
if on.resize then on.resize(w,h) end
app.Last_resize_time = Current_time
end
function love.filedropped()
if Mode == 'error' then return end
if on.filedropped then on.filedropped() end
end
function love.focus(in_focus)
if Mode == 'error' then return end
if in_focus then
app.Last_focus_time = Current_time
end
if on.focus then on.focus(in_focus) end
end
function love.keypressed(key, scancode, isrepeat)
if Mode == 'error' then
if key == 'c' then
love.system.setClipboardText(Error_message)
end
return
end
-- ignore events for some time after window in focus (mostly alt-tab)
if Current_time < app.Last_focus_time + 0.01 then
return
end
if on.key_press then on.key_press(key, scancode, isrepeat) end
end
function love.textinput(t)
if Mode == 'error' then return end
-- ignore events for some time after window in focus (mostly alt-tab)
if Current_time < app.Last_focus_time + 0.01 then
return
end
if on.text_input then on.text_input(t) end
end
function love.keyreleased(key, scancode)
if Mode == 'error' then
Mode = 'run'
return
end
if on.key_release then on.key_release(key, scancode) end
end
function love.mousepressed(x,y, mouse_button)
if Mode == 'error' then return end
if on.mouse_press then on.mouse_press(x,y, mouse_button) end
end
function love.mousereleased(x,y, mouse_button)
if Mode == 'error' then return end
if on.mouse_release then on.mouse_release(x,y, mouse_button) end
end
function love.mousemoved(x,y, dx,dy, istouch)
if Mode == 'error' then return end
if on.mouse_move then on.mouse_move(x,y, dx,dy, istouch) end
end
function love.wheelmoved(dx,dy)
if Mode == 'error' then return end
if on.mouse_wheel_move then on.mouse_wheel_move(dx,dy) end
end
-- === on error, pause the app and wait for messages
function love.run()
love.load(love.arg.parseGameArguments(arg), arg)
love.timer.step()
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
local dt = love.timer.step()
love.update(dt)
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)
Mode = 'error'
local cleaned_up_error = err
if not err:match('stack overflow') then
local callstack = debug.traceback('', --[[stack frame]]2)
cleaned_up_error = 'Error: ' .. cleaned_up_frame(tostring(err))..'\n'..cleaned_up_callstack(callstack)
else
-- call only primitive functions when we're out of stack space
end
app.send_run_time_error(cleaned_up_error)
love.graphics.setFont(love.graphics.newFont(20))
Error_message = 'Something is wrong. Sorry!\n\n'..cleaned_up_error..'\n\n'..
"(Note: function names above don't include outer tables. So functions like on.draw might show up as just 'draw', etc.)\n\n"..
'Options:\n'..
'- press "c" (without the quotes) to copy this message to your clipboard to send to me: ak@akkartik.com\n'..
'- press any other key to retry, see if things start working again\n'..
'- run driver.love to try to fix it yourself. As you do, feel free to ask me questions: ak@akkartik.com\n'
Error_count = Error_count+1
if Error_count > 1 then
Error_message = Error_message..('\n\nThis is error #%d in this session; things will probably not improve in this session. Please copy the message and send it to me: ak@akkartik.com.'):format(Error_count)
end
print(Error_message)
end
-- I tend to read code from files myself (say using love.filesystem calls)
-- rather than offload that to load().
-- Functions compiled in this manner have ugly filenames of the form [string "filename"]
-- This function cleans out this cruft from error callstacks.
-- It also strips out the numeric prefixes we introduce in filenames.
function cleaned_up_callstack(callstack)
local frames = {}
for frame in string.gmatch(callstack, '[^\n]+\n*') do
table.insert(frames, cleaned_up_frame(frame))
end
-- the initial "stack traceback:" line was unindented and remains so
return table.concat(frames, '\n\t')
end
function cleaned_up_frame(frame)
local line = frame:gsub('^%s*(.-)\n?$', '%1')
local filename, rest = line:match('([^:]*):(.*)')
if filename then
return cleaned_up_filename(filename)..':'..rest
else
return line
end
end
function cleaned_up_filename(filename)
-- pass through frames that don't match this format
-- this includes the initial line "stack traceback:"
local core_filename = filename:match('^%[string "(.*)"%]$')
if core_filename == nil then return filename end
-- strip out the numeric prefixes we introduce in filenames
local _, core_filename2 = core_filename:match('^(%d+)-(.+)')
return core_filename2 or core_filename
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)
function starts_with(s, prefix)
if #s < #prefix then
return false
end
for i=1,#prefix do
if s:sub(i,i) ~= prefix:sub(i,i) then
return false
end
end
return true
end
array = {}
function array.find(arr, elem)
if type(elem) == 'function' then
for i,x in ipairs(arr) do
if elem(x) then
return i
end
end
else
for i,x in ipairs(arr) do
if x == elem then
return i
end
end
end
return nil
end