864 lines
27 KiB
Text
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
|