343 lines
11 KiB
Text
343 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. 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
|