--[[ * * 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. * * --]] -- Turns a game description into an ordinary Singe game (PLAN section 58). -- -- The output is Lua a person can read, keep, and edit by hand; the description is kept beside it -- so the editor can open it again. Compiling rather than interpreting costs nothing at build time -- and saves walking a rule table every frame on a Pi or a handheld -- and it means there is no wall -- between what the tools make and what someone writes themselves. -- -- This is written in Lua rather than in util/ as a Python script because the editor is itself a -- Singe game: it has to be able to compile what it is editing without leaving the engine. It -- lives with Forge rather than in Singe/ because only building needs it -- a finished game is -- already Lua. Author.singe, the runtime that 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. The compiler loads it too, for the vocabulary it emits against. -- 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" -- A Lua literal for a value that came out of a description. Numbers and booleans go through as -- they are; everything else is quoted, because an author typing a name must not be able to end -- the string and start writing code. AUTHOR.actions.lua is the one deliberate way in. local function literal(value) if type(value) == "number" or type(value) == "boolean" then return tostring(value) end return string.format("%q", tostring(value)) end -- The table a description carries for an entity's look or behaviour, written back out as source. local function tableSource(t) local parts = {} local keys = {} for key in pairs(t) do keys[#keys + 1] = key end table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) for _, key in ipairs(keys) do parts[#parts + 1] = string.format("%s = %s", key, literal(t[key])) end return "{ " .. table.concat(parts, ", ") .. " }" end -- One condition or action, through the manifest entry that owns it. An unknown name is a fault in -- the description rather than in the generated game, so it is reported here and not emitted. local function emitOne(set, item, what) local entry = set[item[1]] if entry == nil then debugPrint(string.format("Author: no %s called '%s'", what, tostring(item[1]))) return nil end return entry.emit(item) end local function emitConditions(rule) local tests = {} for _, item in ipairs(rule.when or {}) do local test = emitOne(AUTHOR.conditions, item, "condition") if test ~= nil then tests[#tests + 1] = "(" .. test .. ")" end end if #tests == 0 then return "true" end -- Every condition on a rule has to hold. A rule needing "either" is two rules, which reads -- better in a list than a nested group does. return table.concat(tests, " and ") end local function emitRule(out, rule, index) out[#out + 1] = string.format("%s-- %s", INDENT, rule.note or ("rule " .. index)) out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule)) for _, item in ipairs(rule.act or {}) do local statement = emitOne(AUTHOR.actions, item, "action") if statement ~= nil then out[#out + 1] = INDENT .. INDENT .. statement end end out[#out + 1] = INDENT .. "end" out[#out + 1] = "" end -- 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 = {} 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] = "authorBegin({" for _, layer in ipairs(game.layers or {}) do out[#out + 1] = INDENT .. tableSource(layer) .. "," end out[#out + 1] = "})" out[#out + 1] = "" for _, entity in ipairs(game.entities or {}) do local parts = {} parts[#parts + 1] = string.format("id = %q", entity.id) parts[#parts + 1] = string.format("x = %s, y = %s", entity.x or 0, entity.y or 0) parts[#parts + 1] = "look = " .. tableSource(entity.look) if entity.behaviours ~= nil and #entity.behaviours > 0 then local made = {} for _, b in ipairs(entity.behaviours) do made[#made + 1] = tableSource(b) end parts[#parts + 1] = "behaviours = { " .. table.concat(made, ", ") .. " }" end out[#out + 1] = "authorMake({ " .. table.concat(parts, ", ") .. " })" end out[#out + 1] = "" out[#out + 1] = "-- The rules, in the order they were written. Every one is tested every frame." out[#out + 1] = "local function rules()" for index, rule in ipairs(game.rules or {}) do emitRule(out, rule, index) end out[#out + 1] = "end" out[#out + 1] = "" out[#out + 1] = "onKeyPressed = authorKeyDown" out[#out + 1] = "onKeyReleased = authorKeyUp" out[#out + 1] = "onInputPressed = authorSwitchDown" out[#out + 1] = "onInputReleased = authorSwitchUp" out[#out + 1] = "" out[#out + 1] = "function onOverlayUpdate()" out[#out + 1] = INDENT .. "authorFrame(rules)" out[#out + 1] = "" out[#out + 1] = INDENT .. "return OVERLAY_UPDATED" out[#out + 1] = "end" out[#out + 1] = "" return table.concat(out, "\n") end -- Compiles a description file to a game beside it. Returns the path written. -- Copies a file through the VFS, so the source may be inside a database and the destination is an -- ordinary file on disc. Used to put the runtime beside a released game. function authorCopy(fromPath, toPath) local input = io.open(fromPath, "rb") local output if input == nil then debugPrint("Author: cannot read " .. tostring(fromPath)) return false end output = io.open(toPath, "wb") if output == nil then input:close() debugPrint("Author: cannot write " .. tostring(toPath)) return false end output:write(input:read("a")) input:close() output:close() return true end -- The directory part of a path, with its separator, or "" for a bare name. local function directoryOf(path) return (string.match(path, "^(.*[/\\])") or "") end -- Compiles a description and puts the runtime beside the result, because that is what the result -- loads. Building without copying it would produce a game that cannot start. function authorBuild(descriptionFile, outputFile) local chunk = assert(loadfile(descriptionFile)) local game = chunk() local file game.source = descriptionFile file = assert(io.open(outputFile, "w")) file:write(authorCompile(game)) file:close() authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe") return outputFile 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. scene54 asserts exactly that. 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 rule reads far -- better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed. local function isLeaf(t) for key, 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 local sep local open local close 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 parts[#parts + 1] = string.format("%s = %s", key, valueSource(value[key], depth + 1)) end if #parts == 0 then return "{}" end if isLeaf(value) then return "{ " .. table.concat(parts, ", ") .. " }" end sep = ",\n" .. indentOf(depth + 1) open = "{\n" .. indentOf(depth + 1) close = "\n" .. indentOf(depth) .. "}" return open .. table.concat(parts, sep) .. close 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