singe/assets/Forge/AuthorCompile.singe
2026-09-14 22:32:53 -05:00

864 lines
27 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The compiler: a game description to a Singe game (FORGE.md).
--
-- A description is data -- types, rooms, and rules -- and what comes out is a Lua script that
-- calls the runtime in Author.singe. The rules become real functions: a condition is a Lua
-- expression, an action a statement, and a rule with an action that takes time a coroutine. The
-- description travels with the game so it can be opened again; nothing is lost in either
-- direction.
--
-- Written in Lua rather than as a util/ script because the editor is itself a Singe game and has to
-- compile what it is editing without leaving the engine. It lives with Forge rather than in Singe/
-- because only building needs it; Author.singe, the runtime a built game loads, travels with the
-- game -- a copy is written beside it when it is built -- so a game handed to someone who has never
-- installed Forge still runs.
-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy.
AUTHOR_RUNTIME = "Forge/Author.singe"
dofile(AUTHOR_RUNTIME)
local INDENT = "\t"
-- ===== Expressions ============================================================================
--
-- A small, safe language where a number goes: numbers, strings, arithmetic, comparison, and, or,
-- not, self.var, other.var, event.field, an entity's field by name, the game's vars by name, and a
-- few functions. Anything else is a compile error naming the expression, and the lua action is
-- the way past it.
local FUNCTIONS = {
count = "authorCount",
random = "authorRandom",
distance = "authorDistance",
has = "authorHas",
abs = "math.abs",
min = "math.min",
max = "math.max",
floor = "math.floor",
time = "authorTime",
frame = "discGetFrame"
}
local NAMES = {
self = "self",
other = "other",
event = "event",
time = "authorTime()",
frame = "discGetFrame()"
}
local OPERATORS = {
["=="] = "==", ["~="] = "~=", ["!="] = "~=", ["<="] = "<=", [">="] = ">=", ["<"] = "<", [">"] = ">",
["+"] = "+", ["-"] = "-", ["*"] = "*", ["/"] = "/", ["%"] = "%", ["^"] = "^", [".."] = ".."
}
-- The keys of a table in name order, so anything written from one is written the same way each time.
local function sortedKeys(t)
local keys = {}
for key in pairs(t or {}) do
keys[#keys + 1] = key
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
return keys
end
local function tokenize(text)
local tokens = {}
local at = 1
while at <= #text do
local c = text:sub(at, at)
if c:match("%s") then
at = at + 1
elseif c:match("[%d%.]") and text:sub(at):match("^%d") or text:sub(at):match("^%.%d") then
local number = text:sub(at):match("^%d*%.?%d+[eE][-+]?%d+") or text:sub(at):match("^%d*%.?%d+") or text:sub(at):match("^%d+")
tokens[#tokens + 1] = { kind = "number", value = number }
at = at + #number
elseif c:match("[%a_]") then
local name = text:sub(at):match("^[%a_][%w_]*")
tokens[#tokens + 1] = { kind = "name", value = name }
at = at + #name
elseif (c == '"') or (c == "'") then
-- A backslash escapes the next character, so a line can hold its own quote marks.
local value = {}
local scan = at + 1
while (scan <= #text) and (text:sub(scan, scan) ~= c) do
local ch = text:sub(scan, scan)
if ch == "\\" then
local escaped = text:sub(scan + 1, scan + 1)
value[#value + 1] = (escaped == "n") and "\n" or escaped
scan = scan + 2
else
value[#value + 1] = ch
scan = scan + 1
end
end
if scan > #text then
error("unterminated string in: " .. text)
end
tokens[#tokens + 1] = { kind = "string", value = table.concat(value) }
at = scan + 1
else
local two = text:sub(at, at + 1)
if OPERATORS[two] then
tokens[#tokens + 1] = { kind = "op", value = two }
at = at + 2
elseif OPERATORS[c] or c == "(" or c == ")" or c == "," or c == "." then
tokens[#tokens + 1] = { kind = "op", value = c }
at = at + 1
else
error("cannot read '" .. c .. "' in: " .. text)
end
end
end
return tokens
end
-- Recursive descent, lowest precedence first. Every level returns Lua source.
local function parser(text)
local tokens = tokenize(text)
local at = 1
local p = {}
local function peek(kind, value)
local token = tokens[at]
return token and (token.kind == kind) and ((value == nil) or (token.value == value))
end
local function take()
at = at + 1
return tokens[at - 1]
end
local function expect(kind, value)
if not peek(kind, value) then
error("expected " .. (value or kind) .. " in: " .. text)
end
return take()
end
function p.primary()
local token = take()
if token == nil then
error("expression ends early: " .. text)
end
if token.kind == "number" then
return token.value
elseif token.kind == "string" then
return string.format("%q", token.value)
elseif token.kind == "op" and token.value == "(" then
local inner = p.expression()
expect("op", ")")
return "(" .. inner .. ")"
elseif token.kind == "op" and token.value == "-" then
return "(-" .. p.unary() .. ")"
elseif token.kind == "name" then
local name = token.value
if name == "true" or name == "false" or name == "nil" then
return name
end
if name == "not" then
return "(not " .. p.unary() .. ")"
end
if peek("op", "(") then
local target = FUNCTIONS[name]
local args = {}
if target == nil then
error("no function called " .. name .. " in: " .. text)
end
take()
if not peek("op", ")") then
args[#args + 1] = p.expression()
while peek("op", ",") do
take()
args[#args + 1] = p.expression()
end
end
expect("op", ")")
return target .. "(" .. table.concat(args, ", ") .. ")"
end
if peek("op", ".") then
take()
local field = expect("name").value
if name == "event" then
return "event[" .. string.format("%q", field) .. "]"
elseif NAMES[name] and (name == "self" or name == "other") then
return "authorField(" .. name .. ", " .. string.format("%q", field) .. ")"
end
return "authorField(authorEntity(" .. string.format("%q", name) .. "), " .. string.format("%q", field) .. ")"
end
if NAMES[name] then
return NAMES[name]
end
return "AUTHOR_VARS[" .. string.format("%q", name) .. "]"
end
error("unexpected '" .. tostring(token.value) .. "' in: " .. text)
end
function p.unary()
return p.primary()
end
function p.power()
local left = p.unary()
while peek("op", "^") do
take()
left = "(" .. left .. " ^ " .. p.unary() .. ")"
end
return left
end
function p.product()
local left = p.power()
while peek("op", "*") or peek("op", "/") or peek("op", "%") do
local op = take().value
left = "(" .. left .. " " .. op .. " " .. p.power() .. ")"
end
return left
end
function p.sum()
local left = p.product()
while peek("op", "+") or peek("op", "-") or peek("op", "..") do
local op = take().value
left = "(" .. left .. " " .. op .. " " .. p.product() .. ")"
end
return left
end
function p.comparison()
local left = p.sum()
while peek("op") and OPERATORS[tokens[at].value] and tokens[at].value:match("[=<>~!]") do
local op = OPERATORS[take().value]
left = "(" .. left .. " " .. op .. " " .. p.sum() .. ")"
end
return left
end
function p.conjunction()
local left = p.comparison()
while peek("name", "and") do
take()
left = "(" .. left .. " and " .. p.comparison() .. ")"
end
return left
end
function p.expression()
local left = p.conjunction()
while peek("name", "or") do
take()
left = "(" .. left .. " or " .. p.conjunction() .. ")"
end
return left
end
local out = p.expression()
if at <= #tokens then
error("unexpected '" .. tostring(tokens[at].value) .. "' in: " .. text)
end
return out
end
-- An expression as Lua. A number or a boolean given as a value is itself.
function authorExpression(value)
if type(value) == "number" then
return tostring(value)
elseif type(value) == "boolean" then
return tostring(value)
elseif value == nil then
return "nil"
end
return parser(tostring(value))
end
-- ===== Parameters =============================================================================
--
-- A parameter's value as a Lua fragment, by the type the manifest declares for it.
local function fragment(kind, value)
if value == nil then
return nil
end
if kind == "number" or kind == "expression" then
return authorExpression(value)
elseif kind == "boolean" then
return value and "true" or "false"
elseif kind == "entity" then
if value == "self" or value == "other" then
return value
end
return "authorEntity(" .. string.format("%q", value) .. ")"
elseif kind == "scancode" then
return "SCANCODE." .. value .. ".value"
elseif kind == "switch" then
return value
elseif kind == "lua" then
return value
end
return string.format("%q", tostring(value))
end
-- A part's parameters as Lua. One the author left empty takes the manifest's default for it,
-- and one with neither becomes nil, which the checker has already pointed out.
local function fragments(entry, item)
local out = {}
for key, kind in pairs(entry.params or {}) do
local value = item[key]
if (value == nil) and entry.defaults then
value = entry.defaults[key]
end
out[key] = fragment(kind, value) or "nil"
end
return out
end
-- ===== Rules ==================================================================================
local function emitOne(set, item, what)
local name = item[1]
local entry = set[name]
if entry == nil then
debugPrint("Author: no " .. what .. " called '" .. tostring(name) .. "'; skipped")
return nil
end
return entry.emit(fragments(entry, item))
end
-- A condition list is an AND; an entry named any is an OR of what it holds, and an entry named
-- all an AND, nesting as deep as the author likes.
local function emitConditions(list)
local parts = {}
for _, item in ipairs(list or {}) do
local test = emitOne(AUTHOR.conditions, item, "condition")
if test ~= nil then
parts[#parts + 1] = test
end
end
if list and list.any then
local inner = {}
for _, item in ipairs(list.any) do
if item[1] then
local test = emitOne(AUTHOR.conditions, item, "condition")
if test then
inner[#inner + 1] = test
end
else
inner[#inner + 1] = emitConditions(item)
end
end
if #inner > 0 then
parts[#parts + 1] = "(" .. table.concat(inner, " or ") .. ")"
end
end
if list and list.all then
parts[#parts + 1] = emitConditions(list.all)
end
if #parts == 0 then
return "true"
end
return table.concat(parts, " and ")
end
local function ruleWaits(rule)
for _, item in ipairs(rule.act or {}) do
local entry = AUTHOR.actions[item[1]]
if entry and entry.waits then
return true
end
end
return false
end
local function emitRule(out, rule, index)
local event = AUTHOR.events[rule.on or "frame"]
local filter = {}
if event == nil then
debugPrint("Author: no event called '" .. tostring(rule.on) .. "'; rule " .. index .. " skipped")
return
end
for key, kind in pairs(event.filter) do
if rule[key] ~= nil then
filter[#filter + 1] = key .. " = " .. fragment(kind, rule[key])
end
end
out[#out + 1] = INDENT .. "{"
out[#out + 1] = INDENT .. INDENT .. string.format("note = %q, on = %q,", rule.note or ("rule " .. index), rule.on or "frame")
out[#out + 1] = INDENT .. INDENT .. "filter = { " .. table.concat(filter, ", ") .. " },"
out[#out + 1] = INDENT .. INDENT .. string.format("each = %s, room = %s, waits = %s, controls = %s, interrupt = %s, guarded = %s,",
rule.each and string.format("%q", rule.each) or "nil",
rule.room and string.format("%q", rule.room) or "nil",
tostring(ruleWaits(rule)),
tostring(rule.controls ~= false),
tostring(rule.interrupt == true),
tostring((rule.when ~= nil) and ((#rule.when > 0) or (rule.when.any ~= nil) or (rule.when.all ~= nil))))
out[#out + 1] = INDENT .. INDENT .. "run = function(self, other, event)"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "if " .. emitConditions(rule.when) .. " then"
for _, item in ipairs(rule.act or {}) do
local statement = emitOne(AUTHOR.actions, item, "action")
if statement ~= nil then
out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. statement
end
end
out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. "return true"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "end"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "return false"
out[#out + 1] = INDENT .. INDENT .. "end"
out[#out + 1] = INDENT .. "},"
end
-- A dialogue: its start and its nodes; a choice's when is an expression and its act a list of
-- actions, compiled as a rule's are, with self and other absent.
local function emitDialogue(out, name, dialogue)
out[#out + 1] = INDENT .. string.format("[%q] = { start = %q, nodes = {", name, dialogue.start or "start")
-- Nodes in name order, so the same description compiles to the same text every time.
for _, nodeName in ipairs(sortedKeys(dialogue.nodes or {})) do
local node = dialogue.nodes[nodeName]
out[#out + 1] = INDENT .. INDENT .. string.format("[%q] = { name = %q, who = %s, text = %s, seconds = %s, next = %s, choices = {",
nodeName, nodeName,
node.who and string.format("%q", node.who) or "nil",
node.text and string.format("%q", node.text) or "nil",
node.seconds and tostring(node.seconds) or "nil",
node.next and string.format("%q", node.next) or "nil")
for _, choice in ipairs(node.choices or {}) do
local parts = { string.format("text = %q", choice.text or "..."), "next = " .. (choice.next and string.format("%q", choice.next) or "nil"), "once = " .. tostring(choice.once == true) }
if choice.when then
parts[#parts + 1] = "when = function() local self, other, event = nil, nil, {} return " .. emitConditions(type(choice.when) == "table" and choice.when or { { "test", expr = choice.when } }) .. " end"
end
if choice.act then
local body = {}
for _, item in ipairs(choice.act) do
local statement = emitOne(AUTHOR.actions, item, "action")
if statement then
body[#body + 1] = statement
end
end
parts[#parts + 1] = "run = function() local self, other, event = nil, nil, {} " .. table.concat(body, " ") .. " end"
end
out[#out + 1] = INDENT .. INDENT .. INDENT .. "{ " .. table.concat(parts, ", ") .. " },"
end
out[#out + 1] = INDENT .. INDENT .. "} },"
end
out[#out + 1] = INDENT .. "} },"
end
-- ===== Reading and writing a description ======================================================
--
-- The editor produces descriptions, so the format has to survive a round trip: load, change
-- nothing, save, and the result must compile to the same game.
local function literal(value)
if type(value) == "string" then
return string.format("%q", value)
end
return tostring(value)
end
local function indentOf(depth)
return string.rep(INDENT, depth)
end
-- Whether a table holds only leaves, in which case it is written on one line. A condition reads
-- far better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed.
local function isLeaf(t)
for _, value in pairs(t) do
if type(value) == "table" then
return false
end
end
return true
end
-- A value written back as Lua source. A condition or an action is a mixed table -- an array part
-- naming it, plus named parameters -- so both parts have to be written or the name survives and
-- the parameters do not. The array part keeps its order; the named keys are sorted, because a
-- description that reorders itself on every save makes every diff unreadable.
local function valueSource(value, depth)
local parts = {}
local keys = {}
local count = 0
if type(value) ~= "table" then
return literal(value)
end
count = #value
for _, item in ipairs(value) do
parts[#parts + 1] = valueSource(item, depth + 1)
end
for key in pairs(value) do
-- Skip the array part, which has already been written in order.
if not (type(key) == "number" and key >= 1 and key <= count and key == math.floor(key)) then
keys[#keys + 1] = key
end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do
local name = tostring(key)
if not name:match("^[%a_][%w_]*$") then
name = "[" .. literal(key) .. "]"
end
parts[#parts + 1] = string.format("%s = %s", name, valueSource(value[key], depth + 1))
end
if #parts == 0 then
return "{}"
end
if isLeaf(value) then
return "{ " .. table.concat(parts, ", ") .. " }"
end
return "{\n" .. indentOf(depth + 1) .. table.concat(parts, ",\n" .. indentOf(depth + 1)) .. "\n" .. indentOf(depth) .. "}"
end
function authorLoad(path)
local chunk, problem = loadfile(path)
if chunk == nil then
debugPrint("Author: cannot read " .. tostring(path) .. ": " .. tostring(problem))
return nil
end
return chunk()
end
function authorSave(game, path)
local file = assert(io.open(path, "w"))
local copy = {}
-- source says where a description came from and is set by the loader, so writing it back out
-- would make a description that had been through the editor differ from one that had not.
for key in pairs(game) do
if key ~= "source" then
copy[key] = game[key]
end
end
file:write("-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.\n")
file:write("return " .. valueSource(copy, 0) .. "\n")
file:close()
return path
end
function authorCopy(fromPath, toPath)
local input = io.open(fromPath, "rb")
local output
if input == nil then
return false
end
output = io.open(toPath, "wb")
if output == nil then
input:close()
return false
end
output:write(input:read("a"))
input:close()
output:close()
return true
end
-- ===== Checking ===============================================================================
--
-- What the compiler can tell an author before the game runs: a type that is not declared, a
-- condition the manifest does not have, an expression that does not parse. Returns a list of
-- messages, empty when the description is sound.
function authorCheck(game)
local problems = {}
local types = game.types or {}
local function problem(text)
problems[#problems + 1] = text
end
local function checkItems(set, list, what, where)
for _, item in ipairs(list or {}) do
local entry = set[item[1]]
if entry == nil then
problem(where .. ": no " .. what .. " called '" .. tostring(item[1]) .. "'")
else
for key, kind in pairs(entry.params) do
if (kind == "number" or kind == "expression") and (type(item[key]) == "string") then
local ok, err = pcall(parser, item[key])
if not ok then
problem(where .. ": " .. tostring(err))
end
elseif (kind == "type") and (item[key] ~= nil) and (types[item[key]] == nil) then
problem(where .. ": no type called '" .. tostring(item[key]) .. "'")
elseif (item[key] == nil) and (kind ~= "entity") and ((entry.defaults or {})[key] == nil) and not (entry.optional or {})[key] then
problem(where .. ": " .. key .. " is empty")
end
end
end
end
end
-- A field the manifest does not declare is a misspelling nine times in ten, and it is
-- otherwise silently ignored.
local function checkFields(t, declared, skip, where)
for key in pairs(t or {}) do
if (type(key) == "string") and (declared[key] == nil) and not skip[key] then
problem(where .. ": '" .. key .. "' is not something it takes")
end
end
end
for name, entityType in pairs(types) do
local look = entityType.look and AUTHOR.looks[entityType.look.kind] or nil
if entityType.look and (look == nil) then
problem("type " .. name .. ": no look called '" .. tostring(entityType.look.kind) .. "'")
elseif look then
checkFields(entityType.look, look.params, { kind = true }, "type " .. name .. "'s look")
end
for _, b in ipairs(entityType.behaviours or {}) do
local behaviour = AUTHOR.behaviours[b.kind]
if behaviour == nil then
problem("type " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'")
else
checkFields(b, behaviour.params, { kind = true }, "type " .. name .. "'s " .. b.kind)
end
end
checkFields(entityType, { look = true, vars = true, behaviours = true }, {}, "type " .. name)
end
if #(game.rooms or {}) == 0 then
problem("the game has no rooms")
end
for _, room in ipairs(game.rooms or {}) do
for _, entry in ipairs(room.entities or {}) do
if types[entry.type] == nil then
problem("room " .. tostring(room.name) .. ": no type called '" .. tostring(entry.type) .. "'")
end
end
-- A waves track's keys name a type, and are at a moment -- or at a stop, when keyed by one.
for _, track in ipairs(room.tracks or {}) do
for index, key in ipairs(track.spawns or {}) do
local where = "room " .. tostring(room.name) .. ", track " .. tostring(track.name) .. ", wave " .. index
if types[key.type] == nil then
problem(where .. ": no type called '" .. tostring(key.type) .. "'")
end
if (track.key == "stop") and (type(key.at) ~= "string") then
problem(where .. ": at should name a stop")
elseif (track.key ~= "stop") and (type(key.at) ~= "number") then
problem(where .. ": at should be a number")
end
end
end
end
for index, rule in ipairs(game.rules or {}) do
local where = "rule " .. index .. " (" .. tostring(rule.note or "") .. ")"
if rule.on and (AUTHOR.events[rule.on] == nil) then
problem(where .. ": no event called '" .. tostring(rule.on) .. "'")
elseif AUTHOR.events[rule.on or "frame"] then
checkFields(rule, AUTHOR.events[rule.on or "frame"].filter, { note = true, on = true, each = true, room = true, when = true, act = true, controls = true, interrupt = true }, where)
end
if rule.each and (types[rule.each] == nil) then
problem(where .. ": no type called '" .. tostring(rule.each) .. "'")
end
checkItems(AUTHOR.conditions, rule.when, "condition", where)
checkItems(AUTHOR.conditions, rule.when and rule.when.any, "condition", where)
checkItems(AUTHOR.actions, rule.act, "action", where)
end
return problems
end
-- ===== The whole game =========================================================================
-- The whole game, as source. Returns a string; the caller decides where it goes.
--
-- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing
-- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is
-- the engine's, as it is for every game ever written for Singe.
function authorCompile(game)
local out = {}
local data = {}
for key, value in pairs(game) do
if (key ~= "rules") and (key ~= "source") and (key ~= "dialogues") then
data[key] = value
end
end
out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description")
out[#out + 1] = "-- " .. (game.title or "Untitled")
out[#out + 1] = "--"
out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then"
out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other."
out[#out + 1] = ""
out[#out + 1] = 'dofile("Singe/Framework.singe")'
-- The game finds its own directory rather than trusting DIR. Framework sets DIR from the
-- script the *engine was launched with*, which is this file only when the game is played
-- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would
-- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the
-- chunk actually running, either way.
out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")'
out[#out + 1] = 'dofile(here .. "Author.singe")'
out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first."
out[#out + 1] = ""
out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code."
out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")"
out[#out + 1] = ""
out[#out + 1] = "-- The rules. Each is a trigger, its filter, and a function that tests the conditions and"
out[#out + 1] = "-- does the actions; one with an action that waits runs as a coroutine."
out[#out + 1] = "authorRules({"
for index, rule in ipairs(game.rules or {}) do
emitRule(out, rule, index)
end
out[#out + 1] = "})"
out[#out + 1] = ""
if game.dialogues then
out[#out + 1] = "-- The dialogues: nodes of a line and choices, each choice's condition and actions as code."
out[#out + 1] = "authorDialogues({"
for _, name in ipairs(sortedKeys(game.dialogues)) do
emitDialogue(out, name, game.dialogues[name])
end
out[#out + 1] = "})"
out[#out + 1] = ""
end
out[#out + 1] = "onKeyPressed = authorKeyDown"
out[#out + 1] = "onKeyReleased = authorKeyUp"
out[#out + 1] = "onInputPressed = authorSwitchDown"
out[#out + 1] = "onInputReleased = authorSwitchUp"
out[#out + 1] = "onMouseMoved = authorMouseMoved"
out[#out + 1] = "onCollision = authorCollision"
out[#out + 1] = "onTrigger = authorTrigger"
out[#out + 1] = "onNavArrived = authorNavArrived"
out[#out + 1] = "onMidiMessage = authorMidi"
out[#out + 1] = "onSoundCompleted = authorSoundDone"
out[#out + 1] = ""
out[#out + 1] = "function onOverlayUpdate()"
out[#out + 1] = INDENT .. "authorFrame()"
out[#out + 1] = ""
out[#out + 1] = INDENT .. "return OVERLAY_UPDATED"
out[#out + 1] = "end"
out[#out + 1] = ""
return table.concat(out, "\n")
end
local function directoryOf(path)
return (string.match(path, "^(.*[/\\])") or "")
end
-- Compiles a description file to a game beside it, with the runtime beside that, because that is
-- what the result loads. Returns the path written.
function authorBuild(descriptionFile, outputFile)
local chunk = assert(loadfile(descriptionFile))
local game = chunk()
local file
game.source = descriptionFile
for _, problem in ipairs(authorCheck(game)) do
debugPrint("Author: " .. problem)
end
file = assert(io.open(outputFile, "w"))
file:write(authorCompile(game))
file:close()
authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe")
return outputFile
end