--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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 -- kinds, 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 = { ["=="] = "==", ["~="] = "~=", ["!="] = "~=", ["<="] = "<=", [">="] = ">=", ["<"] = "<", [">"] = ">", ["+"] = "+", ["-"] = "-", ["*"] = "*", ["/"] = "/", ["%"] = "%", ["^"] = "^", [".."] = ".." } 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 local function fragments(entry, item) local out = {} for key, kind in pairs(entry.params or {}) do out[key] = fragment(kind, item[key]) 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") for nodeName, node in pairs(dialogue.nodes or {}) do 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 kind 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 kinds = game.kinds 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 == "kind") and (item[key] ~= nil) and (kinds[item[key]] == nil) then problem(where .. ": no kind called '" .. tostring(item[key]) .. "'") 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, kind in pairs(kinds) do local look = kind.look and AUTHOR.looks[kind.look.kind] or nil if kind.look and (look == nil) then problem("kind " .. name .. ": no look called '" .. tostring(kind.look.kind) .. "'") elseif look then checkFields(kind.look, look.params, { kind = true }, "kind " .. name .. "'s look") end for _, b in ipairs(kind.behaviours or {}) do local behaviour = AUTHOR.behaviours[b.kind] if behaviour == nil then problem("kind " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'") else checkFields(b, behaviour.params, { kind = true }, "kind " .. name .. "'s " .. b.kind) end end checkFields(kind, { look = true, vars = true, behaviours = true }, {}, "kind " .. 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 kinds[entry.kind] == nil then problem("room " .. tostring(room.name) .. ": no kind called '" .. tostring(entry.kind) .. "'") 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 (kinds[rule.each] == nil) then problem(where .. ": no kind 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] = "" 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, dialogue in pairs(game.dialogues) do emitDialogue(out, name, dialogue) 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] = "" 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