singe/util/forgePortKaris.lua
2026-09-22 18:32:07 -05:00

699 lines
27 KiB
Lua

-- Writes a Forge description for a KarisFramework game from the game's own script: the script is
-- Lua, so it is loaded with the engine stubbed out, its settings and tables read, and setupMoves
-- called for every level and scene. Nothing is transcribed by hand.
--
-- lua5.4 util/forgePortKaris.lua <game directory> <main script> [<name>]
-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/TimeGal Timegal.singe
-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/hypseus/Astroboy/singe/Astroboy Astroboy.singe
-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/hypseus/starblazers/singe/starblazers starblazers.singe
--
-- The description goes beside the script as <name>.forge (the script's stem unless given). It is
-- data for the qte behaviour in Author.singe, which plays the framework's game loop.
--
-- The framework is found where the game keeps it: KarisFramework/Script beside the script (the
-- library's games), Structure or Script beside it, or Framework or FrameworkKimmy beside the
-- game's folder (the Hypseus zip-ROM layout, singe/<Title>/ and singe/Framework/). Its
-- version is written into the description, since 3.32b differs from 3.31c in a few things a
-- player can see (and MazescaterFramework beside the game, LINEA's Karis 3.31c with compound
-- moves, is marked as such there); a Kimmy Script Engine game gets the kimmy behaviour, and its saves and its
-- die-and-retry records are snapshotted too, since its new-game menu reads them. A game of
-- RDG's map-mode lineage (no framework folder: its globals.singe is beside its script and its
-- levels are functions of its main.singe) gets the rdg behaviour, which runs those functions
-- itself; its moves are not snapshotted, since the game draws some of them at random at play.
local dir = arg[1] or error("a game directory")
local main = arg[2] or error("the main script")
local name = arg[3] or main:gsub("%.singe$", "")
-- ===== Loading the game's script ==============================================================
local env = setmetatable({}, { __index = _G })
local function nothing() return 0 end
for _, key in ipairs({ "soundLoad", "spriteLoad", "fontLoad", "fontSelect", "fontQuality", "colorForeground", "colorBackground", "discGetWidth", "discGetHeight", "overlaySetResolution", "discSetFPS", "debugPrint", "discSkipToFrame", "discPause", "discPlay", "singeSetGameName", "keyboardSetMode", "overlayClear", "singeGetDataPath", "spriteGetWidth", "spriteGetHeight", "setOverlaySize", "setOverlayResolution", "scoreBezelEnable", "scoreBezelGetState", "discAudioSuffix", "singeSetQuitKeyEnabled", "keyboardCatchQuit", "ratioGetX", "ratioGetY", "vldpGetScale", "overlayGetWidth", "overlayGetHeight", "fontGetHeight", "fontGetWidth", "spriteLoadFrames", "mouseGetPosition", "keyboardCatchQuit", "singeSetQuitKeyEnabled" }) do
env[key] = nothing
end
-- Where the framework's globals are, and its version from their header.
local frameworkFile
local frameworkVersion
local kimmy = false
local rdg = false
local timegal = false
local sdq = false
local mazescater = false
-- An American Laser Games title (Mad Dog McCree and its kin): no framework at all, its
-- hitbox files beside the script; the alg behaviour plays it over the level flows written
-- by hand in <stem>.levels.lua beside the script.
local alg = (function()
local handle = io.open(dir .. "/" .. main, "rb")
local text = handle and handle:read("a") or ""
if handle then
handle:close()
end
-- The family's mark: a shot judged against a hitmap of the frame (shooterHit), its
-- hitbox files loaded by name, or the typing edition's own globals.
return (text:find("function shooterHit", 1, true) ~= nil) or (text:find('dofile(MYDIR .. "hitbox-', 1, true) ~= nil) or (text:find("te-globals.singe", 1, true) ~= nil)
end)()
-- Adventures in Videoland (Rollercoaster): a text adventure over the disc, its rooms, objects,
-- and furniture tables in the script; the videoland behaviour plays its loop over them.
local videoland = (function()
local handle = io.open(dir .. "/" .. main, "rb")
local text = handle and handle:read("a") or ""
if handle then
handle:close()
end
return (text:find("STATE_GET_NAME", 1, true) ~= nil) and (text:find("ROOMS", 1, true) ~= nil)
end)()
-- Hologram Time Traveler (RDG2010's Singe edition): its own loop over segment tables shuffled
-- at start, read like the American Laser Games titles (the script is its data) and played by
-- the timetraveler behaviour in the vocabulary beside it.
local timetraveler = (function()
local handle = io.open(dir .. "/" .. main, "rb")
local text = handle and handle:read("a") or ""
if handle then
handle:close()
end
return text:find("TIME TRAVELER (SINGE EDITION)", 1, true) ~= nil
end)()
if videoland or timetraveler then
alg = true
end
-- (hq-globals.singe, Time Gal HD's, comes before a plain globals.singe beside it: that title
-- keeps its Singe 1 ancestor's files too.)
for _, candidate in ipairs({ dir .. "/MazescaterFramework/Script/globals.singe", dir .. "/KarisFramework/Script/globals.singe", dir .. "/Structure/globals.singe", dir .. "/Script/globals.singe", dir .. "/../Framework/globals.singe", dir .. "/../FrameworkKimmy/globals.singe", dir .. "/hq-globals.singe", dir .. "/globals.singe" }) do
local handle = io.open(candidate, "r")
if handle then
local text = handle:read("a") or ""
-- The MazescaterFramework (LINEA): Karis 3.31c with compound moves, its version
-- marked as the framework's own so the loop tells the two apart.
mazescater = (candidate == dir .. "/MazescaterFramework/Script/globals.singe")
frameworkFile = candidate
frameworkVersion = (mazescater and "Mazescater " or "") .. (text:match("VERSION:%s*([%w%.]+)") or "")
kimmy = text:find("KIMMY SCRIPT ENGINE", 1, true) ~= nil
timegal = (candidate == dir .. "/hq-globals.singe")
rdg = (candidate == dir .. "/globals.singe") or timegal
-- Super Don Quixote: map-mode data under a Karis 3.31c judging loop, whose globals
-- name the Karis kinds (HOLDUP and the rest).
sdq = rdg and (not timegal) and (text:find("HOLDUP", 1, true) ~= nil)
handle:close()
break
end
end
if alg then
-- The game's own script is its globals: everything it declares at load is its data.
frameworkFile = dir .. "/" .. main
frameworkVersion = nil
rdg = true
end
assert(frameworkFile, "no framework globals beside " .. dir)
env.singeGetScriptPath = function() return dir .. "/" .. main end
env.random = { new = function() return 0 end }
-- The map-mode scripts seed from os.clock at load; plain Lua wants a whole number for that.
env.os = setmetatable({ clock = function() return 1 end }, { __index = os })
-- A game that asks the filesystem whether its saved config exists (cfgReadPath, through lfs)
-- hears that it does not, and reads the shipped copy.
env.require = function() return { attributes = function() return nil end } end
-- A game that reads its config at load (Mad Dog McCree's readConfig runs as the script does)
-- reads the real file when the path names one, and nothing otherwise.
local ioCurrent = nil
env.io = { open = function() return nil end,
input = function(path)
-- The path as given, else its name under the game's Cfg/ folder (the script's
-- MYDIR is a stub here).
local base = (type(path) == "string") and path:match("([^/\\]+)$") or nil
ioCurrent = nil
for _, candidate in ipairs({ path, base and (dir .. "/../Cfg/" .. base), base and (dir .. "/Cfg/" .. base), base and (dir .. "/" .. base) }) do
if (type(candidate) == "string") and (ioCurrent == nil) then
ioCurrent = io.open(candidate, "r")
end
end
return ioCurrent
end,
output = function() return nil end,
read = function(what)
if ioCurrent then
return ioCurrent:read(what or "l")
end
return nil
end,
write = function() end,
close = function(handle)
if handle and (handle == ioCurrent) then
ioCurrent:close()
ioCurrent = nil
end
end,
lines = function() return function() return nil end end }
env.mouseHowMany = function() return 0 end
env.discSearch = function() end
env.discSetFPS = function() end
env.singeWantsCrosshairs = function() return false end
-- A setupMoves that reads the game's state (Space Ace picks a scene by which levels are
-- beaten) sees nothing beaten here; the qte behaviour runs the script itself as it plays.
env.stage = setmetatable({}, { __index = function() return { false, false, 0 } end })
env.scene = setmetatable({}, { __index = function() return setmetatable({}, { __index = function() return { 0, false } end }) end })
env.Tiers = {}
env.Level = {}
env.Death = {}
env.move = {}
env.choice = {}
env.path = {}
env.timed = {}
env.dofile = function(path)
if alg and (path ~= frameworkFile) then
-- The title's own files (its hitbox tables, its board, its service) run here too,
-- under the stubs: the script calls what they define at load (readConfig).
-- (Beside the script, or in a Script/ folder beside it, as the HD copies keep theirs.)
local base = path:match("([^/\\]+)$")
local own = loadfile(dir .. "/" .. base, "t", env) or loadfile(dir .. "/Script/" .. base, "t", env)
if own then
own()
end
return
end
if path:match("globals%.singe$") then
-- The framework's globals name the moves; what it loads beyond that is stubbed.
local chunk = assert(loadfile(frameworkFile, "t", env))
local inner = env.dofile
env.dofile = function() end
chunk()
env.dofile = inner
end
end
if alg then
-- A hand-written game touches whatever engine calls it likes at load (videoGetVolume, ...):
-- any name the stubs do not know answers as one that does nothing.
for _, name in ipairs({ "pairs", "ipairs", "next", "type", "tonumber", "tostring", "select", "pcall", "error", "assert", "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", "print", "unpack" }) do
if rawget(env, name) == nil then
env[name] = _G[name] or (name == "unpack" and table.unpack) or nil
end
end
setmetatable(env, { __index = function(_, key)
local real = _G[key]
if real ~= nil then
return real
end
return nothing
end })
-- Its framework.singe defines the switch names and the defaults the script reads.
local frame = loadfile(dir .. "/framework.singe", "t", env)
if frame then
frame()
end
end
-- The game's own directory (the Script/ folder's parent when it has one), for the files the
-- script joins onto MYDIR at load.
env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir
assert(loadfile(dir .. "/" .. main, "t", env))()
-- ===== The settings ===========================================================================
-- The dips, by name, from what the game was last saved with or shipped with: the framework
-- reads Cfg/game.cfg and falls back to Cfg/default.cfg.
local function settings()
local found = {}
for _, file in ipairs({ dir .. "/Cfg/game.cfg", dir .. "/Cfg/default.cfg" }) do
local handle = io.open(file, "r")
if handle then
for line in handle:lines() do
local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)")
if key and (found[key] == nil) then
found[key] = tonumber(value)
end
end
handle:close()
end
end
return found
end
-- ===== The moves ==============================================================================
local function copy(t)
local out = {}
for key, value in pairs(t or {}) do
-- A function is not data (a stub of the converter's, a game's own helper).
if type(value) == "table" then
out[key] = copy(value)
elseif type(value) ~= "function" then
out[key] = value
end
end
return out
end
-- A game's moves may differ by difficulty (Space Ace's do: setupMoves reads dip_Difficulty):
-- each scene is read at every difficulty, and kept once when they agree.
local function scenesAt(level, difficulty)
local scenes = {}
env.dip_Difficulty = difficulty
for scene = 1, env.Level[level][4] do
env.move = {}
env.choice = {}
env.path = {}
env.timed = {}
env.sceneStart = nil
env.sceneEnd = nil
env.totalMoves = 0
env.setupMoves(level, scene)
local moves = {}
for index = 1, env.totalMoves or 0 do
moves[index] = copy(env.move[index])
end
scenes[scene] = { start = env.sceneStart, finish = env.sceneEnd, moves = moves, choices = copy(env.choice), paths = copy(env.path), timed = copy(env.timed) }
end
return scenes
end
local function same(a, b)
if type(a) ~= type(b) then
return false
end
if type(a) ~= "table" then
return a == b
end
for key, value in pairs(a) do
if not same(value, b[key]) then
return false
end
end
for key in pairs(b) do
if a[key] == nil then
return false
end
end
return true
end
-- The scenes of a level: play, or playBy difficulty when the difficulties differ.
local function scenesOf(level)
local by = {}
local differ = false
for difficulty = 0, 3 do
by[difficulty] = scenesAt(level, difficulty)
if not same(by[difficulty], by[0]) then
differ = true
end
end
env.dip_Difficulty = nil
if differ then
return nil, by
end
return by[0], nil
end
-- ===== Writing ================================================================================
local out = {}
local function put(line)
out[#out + 1] = line
end
local function lua(value, indent)
indent = indent or ""
if type(value) == "table" then
local keys = {}
local isList = (#value > 0)
local parts = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(a, b)
if type(a) == type(b) then
return a < b
end
return type(a) == "number"
end)
for _, key in ipairs(keys) do
local item = lua(value[key], indent .. "\t")
if isList and (type(key) == "number") and (key >= 1) and (key <= #value) and (math.floor(key) == key) then
parts[#parts + 1] = item
elseif type(key) == "string" and key:match("^[%a_][%w_]*$") then
parts[#parts + 1] = key .. " = " .. item
else
parts[#parts + 1] = "[" .. lua(key) .. "] = " .. item
end
end
local text = "{ " .. table.concat(parts, ", ") .. " }"
if #text > 110 then
text = "{\n" .. indent .. "\t" .. table.concat(parts, ",\n" .. indent .. "\t") .. "\n" .. indent .. "}"
end
return text
elseif type(value) == "string" then
return string.format("%q", value)
end
return tostring(value)
end
local levels = {}
-- A map-mode game's levels are functions of its main.singe, run at play; nothing to read here.
for index = 1, (rdg and 0 or env.finalstage) do
local level = env.Level[index]
local play, playBy = scenesOf(index)
levels[index] = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy }
end
if (not rdg) and env.AllowSecret and env.Level[env.levelSecret] then
local level = env.Level[env.levelSecret]
local play, playBy = scenesOf(env.levelSecret)
levels.secret = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy }
end
local tiers = {}
for index = 0, (env.Tiers and env.Tiers[0] and env.Tiers[0][1]) or 0 do
tiers[index + 1] = copy(env.Tiers[index])
end
local dips = settings()
-- A map-mode game keeps its dips and its board in one file, the one its service.singe reads
-- (game.cfg or the game's own name beside the script; the later copies read theirs from Cfg/
-- beside the Script/ folder, through cfgReadPath): the dips as name = value lines (the names
-- are the game's own), a blank, then ten lines of NAME,score.
local function mapModeConfigFile()
local handle = (timegal and io.open(dir .. "/hq-service.singe", "rb")) or io.open(dir .. "/service.singe", "rb") or io.open(dir .. "/Script/service.singe", "rb") or io.open(dir .. "/te-service.singe", "rb")
local name = "game.cfg"
if handle then
local text = handle:read("a")
handle:close()
-- The config the game reads first: through cfgReadPath (the later copies, whose
-- MYDIR reads name a default.cfg fallback too), else joined onto MYDIR.
name = text:match('io%.input%(cfgReadPath%("([^"]+)"%)') or text:match('io%.input%(MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input,%s*MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input%("[^"]-([^"/]+%.cfg)"%)') or name
name = name:gsub("^/", "")
end
for _, candidate in ipairs({ dir .. "/" .. name, dir .. "/../Cfg/" .. name, dir .. "/../" .. name }) do
local found = io.open(candidate, "r")
if found then
found:close()
return candidate
end
end
return dir .. "/" .. name
end
local function mapModeConfig()
local found = {}
local board = {}
local extra = {} -- Super Don Quixote's best percent per level, after its board.
local handle = io.open(mapModeConfigFile(), "r")
if handle then
for line in handle:lines() do
local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)")
local who, score = line:match("^%s*([^,=]+),(%d+)%s*$")
if key then
found[key] = tonumber(value)
elseif who and (#board < 10) then
board[#board + 1] = { who, tonumber(score) }
elseif who then
extra[#extra + 1] = { who, tonumber(score) }
end
end
handle:close()
end
return found, board, extra
end
-- What a map-mode title does differently from the lineage, by the script's stem: the sound of
-- a right move when it is not sndright, and how long the intro's command screen holds.
local RDG_TWEAKS = {
freedomfighter = { rightSound = "sndshot", introHold = 15 }
}
if rdg then
local rdgDips, rdgBoard, rdgExtra = mapModeConfig()
dips = rdgDips
RDG_BOARD = rdgBoard
RDG_EXTRA = rdgExtra
end
-- Super Don Quixote's level order is two tables hardcoded in its doLevel: the level after a
-- beaten one, and the level after a scene that did not beat its level (the game alternates
-- two levels' scenes); read from the script's text as the game's data.
local function sdqOrder()
local handle = io.open(dir .. "/main.singe", "rb")
local beaten = {}
local unfinished = {}
if handle == nil then
return nil
end
local text = handle:read("a")
handle:close()
local body = text:match("function doLevel%(%).-\nend") or ""
local split = body:find("if CompleteLevel then", 1, true) or #body
local before, after = body:sub(1, split), body:sub(split)
for from, to in before:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do
beaten[tonumber(from)] = tonumber(to)
end
for from, to in after:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do
unfinished[tonumber(from)] = tonumber(to)
end
return { beaten = beaten, unfinished = unfinished }
end
-- The high score board the game ships with (Cfg/hscore.cfg: ten lines of NAME,score): a score
-- that reaches it goes to the board's screens at the end.
local function highScores()
local found = {}
local handle = io.open(dir .. "/Cfg/hscore.cfg", "r")
if handle then
for line in handle:lines() do
local who, score = line:match("^%s*([^,]+),(%d+)")
if who and (#found < 10) then
found[#found + 1] = { who, tonumber(score) }
end
end
handle:close()
end
return found
end
-- Kimmy's "die and retry" records, one per difficulty: the four lines after the three boards
-- and the two trophy blocks of Cfg/hscore.cfg, read as the framework reads them (a line
-- without a comma-and-name counts 100).
local function dieAndRetry()
local records = { 100, 100, 100, 100 }
local handle = io.open(dir .. "/Cfg/hscore.cfg", "r")
if handle == nil then
return records
end
local lines = {}
for line in handle:lines() do
lines[#lines + 1] = line
end
handle:close()
-- 10 + blank, 10 + blank, 10 + blank, 4 + blank, 4 + blank: the records begin at line 44.
for k = 1, 4 do
local line = lines[43 + k]
local score = line and (line:find("!", 1, true) == nil) and line:match("^[^,]*,(.*)$")
records[k] = tonumber(score) or 100
end
return records
end
-- Kimmy's save slots, Cfg/s1.cfg to s6.cfg: a line of eleven fields in the framework's
-- punctuation, then a line per level (its order, started, beaten, deaths).
local function saves()
local slots = {}
for slot = 1, 6 do
local handle = io.open(dir .. "/Cfg/s" .. slot .. ".cfg", "r")
if handle then
local first = handle:read("l") or ""
local line = {}
local rest = first
for _, mark in ipairs({ ",", "!", "?", ";", ":", "A", "B", "C", "D", "E", "F" }) do
local at = rest:find(mark, 1, true)
if at == nil then
break
end
line[#line + 1] = rest:sub(1, at - 1)
rest = rest:sub(at + 1)
end
local levels = {}
for entry in handle:lines() do
local order, started, beaten, deaths = entry:match("^(%d+)A(%a+)B(%a+)C(%d+)D")
if order then
levels[#levels + 1] = { tonumber(order), started == "true", beaten == "true", tonumber(deaths) }
end
end
handle:close()
slots[slot] = { line = line, levels = levels }
end
end
return slots
end
-- Everything the game's script declared, under its own names: the scores, the offsets, the
-- flags, the Level and Death tables, the play order and the tiers. The qte behaviour takes
-- these as its state, and runs the script itself when it can, for a setupMoves that reads the
-- state as it plays.
local settings = {}
for key, value in pairs(env) do
local kind = type(value)
if (kind == "number") or (kind == "string") or (kind == "boolean") or (kind == "table") then
if not (key == "move" or key == "choice" or key == "path" or key == "timed" or key == "io" or key == "os" or key == "string" or key == "math" or key == "table" or key == "sceneStart" or key == "sceneEnd" or key == "totalMoves" or key == "dip_Difficulty" or key == "MYDIR" or key == "BASEDIR" or key == "SINGE_LEGACY_SPRITE_ARGS" or key == "random" or key == "os") then
settings[key] = (kind == "table") and copy(value) or value
end
end
end
-- (A game that sets its rate by number rather than by name has no MovieFPS; under the
-- hand-written games' catch-all stubs the name answers as a function, which is not data.)
settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil
-- The level flows of an American Laser Games title, written by hand beside its script.
local function algLevels()
local chunk = loadfile(dir .. "/" .. main:gsub("%.singe$", "") .. ".levels.lua", "t", {})
return chunk and chunk() or nil
end
local qte = {
script = main,
addons = (not rdg) and "Script/addons.singe" or nil,
framework = frameworkVersion,
hsDR = kimmy and dieAndRetry() or nil,
saves = kimmy and saves() or nil,
settings = settings,
dips = dips,
highScores = rdg and RDG_BOARD or highScores(),
tweaks = rdg and RDG_TWEAKS[name] or nil,
sdq = sdq and sdqOrder() or nil,
percents = sdq and RDG_EXTRA or nil,
flows = alg and algLevels() or nil,
levels = (not rdg) and levels or nil
}
if videoland then
-- The objects are set up by the game's own reset; the rooms, furniture, and directions
-- stand as the script declares them.
env.resetGameData()
put("-- " .. name .. ", ported to Forge from its Adventures in Videoland script by util/forgePortKaris.lua:")
put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop")
put("-- under the console look; both live in Vocabulary.singe beside this file.")
put("return {")
put("\ttitle = " .. lua(name) .. ",")
put("\tvocabulary = \"Vocabulary.singe\",")
put("\tplayers = 1,")
put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
put("\tvars = { room = 1, turns = 0 },")
put("")
put("\ttypes = {")
put("\t\tgame = { look = { kind = \"console\" }, behaviours = { { kind = \"videoland\" } } }")
put("\t},")
put("")
put("\trooms = {")
put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }")
put("\t},")
put("")
put("\trules = {},")
put("")
put("\tvideoland = " .. lua({ script = main, rooms = env.ROOMS, objects = env.OBJECTS, furniture = env.FURNITURE, directions = env.DIRECTIONS, turns = 150 }, "\t"))
put("}")
local path = dir .. "/" .. name .. ".forge"
local file = assert(io.open(path, "w"))
file:write(table.concat(out, "\n") .. "\n")
file:close()
print("wrote " .. path .. ": " .. #env.ROOMS .. " rooms, " .. #env.OBJECTS .. " objects")
return
end
local loop = (timetraveler and "timetraveler") or (alg and "alg") or (timegal and "timegal" or (sdq and "sdq" or (rdg and "rdg" or (kimmy and "kimmy" or "qte"))))
put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its " .. (timetraveler and "Time Traveler (Singe Edition)" or alg and "American Laser Games" or (timegal and "Time Gal (Singe Edition)" or (rdg and "map-mode" or (kimmy and "Kimmy Script Engine" or (mazescater and "MazescaterFramework" or "KarisFramework"))))) .. " script by util/forgePortKaris.lua:")
put("-- the framework's tables as data for the " .. loop .. " behaviour, which plays the framework's game loop.")
put("return {")
put("\ttitle = " .. lua(name) .. ",")
if alg then
-- The title's mechanics and screens are a vocabulary beside its script.
put("\tvocabulary = \"Vocabulary.singe\",")
end
put("\tplayers = 1,")
put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },")
put("")
put("\ttypes = {")
-- A hand-written title's vocabulary draws its HUD as its own files do (the hud look); a
-- framework's is drawn by the runtime's look for that loop.
put("\t\tgame = { look = { kind = \"" .. (alg and "hud" or (loop .. "Hud")) .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
put("\t},")
put("")
put("\trooms = {")
put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }")
put("\t},")
put("")
put("\trules = {},")
put("")
put("\tqte = " .. lua(qte, "\t"))
put("}")
local path = dir .. "/" .. name .. ".forge"
local file = assert(io.open(path, "w"))
file:write(table.concat(out, "\n") .. "\n")
file:close()
print("wrote " .. path .. ": " .. #levels .. " levels, " .. #env.Death .. " deaths")