singe/assets/Forge/AuthorCompile.singe
2026-09-13 22:06:39 -05:00

357 lines
11 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.
*
*
--]]
-- 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. Singe/Author.singe, the runtime that game loads, has to stay an engine support
-- file: a game handed to someone who has never installed Forge still has to run.
dofile("Singe/Author.singe")
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 = {}
local key
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 = {}
local item
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)
local item
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 = {}
local entity
local layer
local rule
out[#out + 1] = "-- Generated by Singe/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 = {}
local b
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
-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy.
AUTHOR_RUNTIME = "Forge/Author.singe"
-- 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)
local key
local value
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 key
local item
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 = {}
local key
-- 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 Singe/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