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

546 lines
16 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.
*
*
--]]
-- The runtime an authored game is compiled against (PLAN section 58).
--
-- A game made with the authoring tools is not interpreted: Singe/AuthorCompile.singe turns its
-- description into ordinary Lua, and this file is the library that Lua calls. Rules become real
-- `if` statements, so nothing walks a table every frame and the result can be opened in ZeroBrane
-- and edited by hand like any other game.
--
-- Three nouns, and no notion of genre anywhere:
--
-- layers which of the engine's own layers the game uses -- disc, overlay, scene, GUI.
-- A genre is only a choice of layers; see AUTHOR.layers below.
-- entities a thing on a layer: a node, a look, and state.
-- behaviours a bundle attached to an entity, over engine calls that already exist.
--
-- AUTHOR is also the manifest the compiler and (later) the editor read: every condition, action
-- and behaviour declares its parameters and the Lua it emits, so a new genre is a pack of entries
-- here rather than a new release of anything.
AUTHOR = {
layers = {},
looks = {},
behaviours = {},
conditions = {},
actions = {}
}
AUTHOR_WORLD = {} -- Entities by id.
AUTHOR_ORDER = {} -- and in the order they were declared, which is draw order.
AUTHOR_LAYERS = {}
AUTHOR_STARTED = 0 -- singeGetTicks() when the game began.
local GRAVITY_DEFAULT = 1400 -- Overlay units a second squared; a 480 tall screen wants about this.
local PLAYER_RADIUS = 0.5 -- Fractions of an entity's box, for the capsule a platformer stands in.
AUTHOR_FONT_POINTS = 18 -- The default the text look draws at.
-- Seconds since the game started. Every rule that talks about time uses this, so a game is
-- reproducible under --deterministic rather than depending on how fast the machine draws.
function authorTime()
return (singeGetTicks() - AUTHOR_STARTED) / 1000.0
end
function authorEntity(id)
return AUTHOR_WORLD[id]
end
-- ===== Layers =================================================================================
--
-- Each declares what it needs of the engine. Nothing here knows what kind of game is being made.
AUTHOR.layers.world2d = {
help = "A flat world with gravity: physics in the XY plane, drawn into the overlay.",
params = { gravity = "number" },
begin = function(layer)
physicsSetEnabled(true)
physicsSet2D(true)
physicsSetGravity(0, layer.gravity or GRAVITY_DEFAULT, 0)
end
}
AUTHOR.layers.overlay = {
help = "Flat drawing over everything: scores, prompts, a HUD.",
params = {},
begin = function() end
}
AUTHOR.layers.disc = {
help = "The video the game is played over.",
params = { start = "number" },
begin = function(layer)
discPlay()
if layer.start then
discSkipToFrame(layer.start)
end
end
}
-- ===== Looks ==================================================================================
--
-- How an entity is drawn. A look draws itself; it never knows which layer it is on.
AUTHOR.looks.box = {
help = "A filled rectangle, centred on the entity.",
params = { w = "number", h = "number", r = "number", g = "number", b = "number" },
draw = function(entity)
local look = entity.look
local x, y = authorPosition(entity)
local hw = look.w / 2
local hh = look.h / 2
local row
colorForeground(look.r or 255, look.g or 255, look.b or 255, 255)
-- overlayBox outlines; a solid block is the outline drawn every row, which is cheap at
-- these sizes and saves the game needing an image for a placeholder.
for row = math.floor(y - hh), math.floor(y + hh) do
overlayLine(x - hw, row, x + hw, row)
end
end
}
AUTHOR.looks.sprite = {
help = "An image, centred on the entity.",
params = { file = "file" },
load = function(entity)
entity.sprite = spriteLoad(entity.look.file)
end,
draw = function(entity)
local x, y = authorPosition(entity)
spriteDraw(entity.sprite, x - spriteGetWidth(entity.sprite) / 2, y - spriteGetHeight(entity.sprite) / 2)
end
}
AUTHOR.looks.text = {
help = "A line of text, its top left at the entity.",
params = { text = "string", r = "number", g = "number", b = "number" },
draw = function(entity)
local x, y = authorPosition(entity)
colorForeground(entity.look.r or 255, entity.look.g or 255, entity.look.b or 255, 255)
if entity.text ~= "" then
fontPrint(x, y, entity.text)
end
end
}
-- ===== Behaviours =============================================================================
--
-- Each is a bundle over engine calls that already exist and are tested. A platformer is a
-- checkbox over the character controller, not a reimplementation of one.
AUTHOR.behaviours.solid = {
help = "Immovable ground or a wall.",
params = {},
attach = function(entity)
bodyNew(entity.node, BODY_STATIC, SHAPE_BOX, entity.look.w / 2, entity.look.h / 2, entity.look.w / 2)
end
}
AUTHOR.behaviours.platformer = {
help = "Runs, falls and jumps: the engine's character controller in 2D.",
params = { speed = "number", jump = "number" },
attach = function(entity, params)
entity.speed = params.speed or 200
entity.jump = params.jump or 500
playerNew(entity.node, SHAPE_CAPSULE, entity.look.w * PLAYER_RADIUS, entity.look.h)
end,
step = function(entity)
-- The rules say which way; this clears it each frame so releasing a key stops the run.
playerMove(entity.node, entity.drive * entity.speed)
entity.drive = 0
end
}
AUTHOR.behaviours.drift = {
help = "Moves steadily, for a cloud, a platform or a target.",
params = { vx = "number", vy = "number" },
attach = function(entity, params)
entity.vx = params.vx or 0
entity.vy = params.vy or 0
end,
step = function(entity, dt)
local x, y, z = nodeGetPosition(entity.node)
nodeSetPosition(entity.node, x + entity.vx * dt, y + entity.vy * dt, z)
end
}
-- ===== Conditions =============================================================================
--
-- Every entry emits a Lua expression. The helpers they call are further down; keeping the test
-- in a named function rather than inlining it is what makes the generated game readable.
AUTHOR.conditions.keyHeld = {
help = "A key is down.",
params = { key = "scancode" },
emit = function(p) return string.format("authorKeyHeld(SCANCODE.%s)", p.key) end
}
AUTHOR.conditions.switchHeld = {
help = "A pad or gun switch is down.",
params = { switch = "switch" },
emit = function(p) return string.format("authorSwitchHeld(%s)", p.switch) end
}
AUTHOR.conditions.timeBetween = {
help = "The game is between two moments, in seconds.",
params = { from = "number", to = "number" },
emit = function(p) return string.format("authorTime() >= %s and authorTime() < %s", p.from, p.to) end
}
AUTHOR.conditions.discBetween = {
help = "The disc is between two frames -- the window a QTE is answered in.",
params = { from = "number", to = "number" },
emit = function(p) return string.format("authorDiscBetween(%s, %s)", p.from, p.to) end
}
AUTHOR.conditions.onGround = {
help = "An entity has ground under it.",
params = { entity = "entity" },
emit = function(p) return string.format("authorOnGround(%q)", p.entity) end
}
AUTHOR.conditions.touching = {
help = "Two entities overlap.",
params = { entity = "entity", other = "entity" },
emit = function(p) return string.format("authorTouching(%q, %q)", p.entity, p.other) end
}
AUTHOR.conditions.below = {
help = "An entity has fallen past a line -- a pit, or the bottom of the screen.",
params = { entity = "entity", y = "number" },
emit = function(p) return string.format("authorBelow(%q, %s)", p.entity, p.y) end
}
AUTHOR.conditions.flagSet = {
help = "A named flag the rules set themselves.",
params = { flag = "string" },
emit = function(p) return string.format("AUTHOR_FLAG[%q] == true", p.flag) end
}
AUTHOR.conditions.once = {
help = "Only the first time this rule would run.",
params = { tag = "string" },
emit = function(p) return string.format("authorOnce(%q)", p.tag) end
}
-- ===== Actions ================================================================================
--
-- Every entry emits a Lua statement.
AUTHOR.actions.run = {
help = "Drive a platformer left (-1) or right (1) this frame.",
params = { entity = "entity", direction = "number" },
emit = function(p) return string.format("authorRun(%q, %s)", p.entity, p.direction) end
}
AUTHOR.actions.jump = {
help = "Ask a platformer to jump.",
params = { entity = "entity" },
emit = function(p) return string.format("authorJump(%q)", p.entity) end
}
AUTHOR.actions.moveTo = {
help = "Put an entity somewhere.",
params = { entity = "entity", x = "number", y = "number" },
emit = function(p) return string.format("authorMoveTo(%q, %s, %s)", p.entity, p.x, p.y) end
}
AUTHOR.actions.setText = {
help = "Change what a text entity says.",
params = { entity = "entity", text = "expression" },
emit = function(p) return string.format("authorSetText(%q, %s)", p.entity, p.text) end
}
AUTHOR.actions.show = {
help = "Show or hide an entity.",
params = { entity = "entity", visible = "boolean" },
emit = function(p) return string.format("authorShow(%q, %s)", p.entity, tostring(p.visible)) end
}
AUTHOR.actions.addScore = {
help = "Add to the score.",
params = { amount = "number" },
emit = function(p) return string.format("AUTHOR_SCORE = AUTHOR_SCORE + %s", p.amount) end
}
AUTHOR.actions.setFlag = {
help = "Set or clear a named flag.",
params = { flag = "string", value = "boolean" },
emit = function(p) return string.format("AUTHOR_FLAG[%q] = %s", p.flag, tostring(p.value)) end
}
AUTHOR.actions.discTo = {
help = "Send the disc to a frame -- the branch a QTE takes.",
params = { frame = "number" },
emit = function(p) return string.format("discSkipToFrame(%s)", p.frame) end
}
AUTHOR.actions.lua = {
help = "Anything the vocabulary cannot say. The way out, and it is meant to be used.",
params = { code = "lua" },
emit = function(p) return p.code end
}
-- ===== The runtime the generated Lua calls ====================================================
AUTHOR_SCORE = 0
AUTHOR_FLAG = {}
local onceSeen = {}
local lastTime = 0
-- Where an entity is, in overlay coordinates. Everything is a node, whether or not the scene is
-- drawing: nodes exist without a GPU, which is what lets a 2D game run on a machine with none.
function authorPosition(entity)
local x, y = nodeGetPosition(entity.node)
return x, y
end
function authorKeyHeld(scancode)
return AUTHOR_KEYS[scancode] == true
end
function authorSwitchHeld(switch)
return AUTHOR_SWITCHES[switch] == true
end
function authorDiscBetween(from, to)
local frame = discGetFrame()
return (frame >= from) and (frame < to)
end
function authorOnGround(id)
return playerIsOnGround(AUTHOR_WORLD[id].node)
end
function authorBelow(id, y)
local _, ey = authorPosition(AUTHOR_WORLD[id])
return ey > y
end
function authorTouching(idA, idB)
local a = AUTHOR_WORLD[idA]
local b = AUTHOR_WORLD[idB]
local ax, ay = authorPosition(a)
local bx, by = authorPosition(b)
return collideRects(ax - a.look.w / 2, ay - a.look.h / 2, a.look.w, a.look.h,
bx - b.look.w / 2, by - b.look.h / 2, b.look.w, b.look.h)
end
-- True the first time only. Rules run every frame, so anything that should happen once -- a door
-- opening, a score awarded -- needs this rather than a flag the author has to remember to clear.
function authorOnce(tag)
if onceSeen[tag] then
return false
end
onceSeen[tag] = true
return true
end
function authorRun(id, direction)
AUTHOR_WORLD[id].drive = direction
end
function authorJump(id)
local entity = AUTHOR_WORLD[id]
playerJump(entity.node, entity.jump)
end
function authorMoveTo(id, x, y)
local entity = AUTHOR_WORLD[id]
if entity.player then
playerSetPosition(entity.node, x, y, 0)
else
nodeSetPosition(entity.node, x, y, 0)
end
end
function authorSetText(id, text)
AUTHOR_WORLD[id].text = tostring(text)
end
function authorShow(id, visible)
AUTHOR_WORLD[id].visible = visible
end
-- Builds one entity from its description. Called by the generated game, once each, at startup.
function authorMake(description)
local entity = {
id = description.id,
look = description.look,
behaviours = description.behaviours or {},
text = (description.look and description.look.text) or "",
visible = true,
drive = 0,
node = nodeNew()
}
local look = AUTHOR.looks[entity.look.kind]
local b
nodeSetPosition(entity.node, description.x or 0, description.y or 0, 0)
if look.load then
look.load(entity)
end
AUTHOR_WORLD[entity.id] = entity
AUTHOR_ORDER[#AUTHOR_ORDER + 1] = entity
for _, b in ipairs(entity.behaviours) do
local kind = AUTHOR.behaviours[b.kind]
if kind == nil then
debugPrint("Author: no behaviour called '" .. tostring(b.kind) .. "'")
else
entity.player = entity.player or (b.kind == "platformer")
kind.attach(entity, b)
end
end
return entity
end
-- Starts the layers the game declared. Anything a layer needs of the engine is asked for here and
-- nowhere else, which is what keeps the rest of this file free of genre.
function authorBegin(layers)
local layer
-- A font, because the text look draws with fontPrint and fontPrint ends the game when none is
-- selected. Every test had a scene select one first; a released game has nobody to do that,
-- and died on its first text entity. A game that wants its own calls fontSelect afterwards.
if AUTHOR_FONT == nil then
AUTHOR_FONT = fontLoad("Singe/FreeSansBold.ttf", AUTHOR_FONT_POINTS)
fontSelect(AUTHOR_FONT)
fontQuality(FONT_QUALITY_BLENDED)
end
AUTHOR_STARTED = singeGetTicks()
lastTime = 0
for _, layer in ipairs(layers) do
local kind = AUTHOR.layers[layer.kind]
if kind == nil then
debugPrint("Author: no layer called '" .. tostring(layer.kind) .. "'")
else
AUTHOR_LAYERS[#AUTHOR_LAYERS + 1] = layer
kind.begin(layer)
end
end
end
-- One frame: the behaviours step, then the rules the compiler wrote, then everything is drawn in
-- the order it was declared. The generated game calls this from onOverlayUpdate and does no more.
function authorFrame(rules)
local now = authorTime()
local dt = now - lastTime
local entity
lastTime = now
for _, entity in ipairs(AUTHOR_ORDER) do
local b
for _, b in ipairs(entity.behaviours) do
local kind = AUTHOR.behaviours[b.kind]
if kind ~= nil and kind.step ~= nil then
kind.step(entity, dt)
end
end
end
if rules ~= nil then
rules()
end
overlayClear()
for _, entity in ipairs(AUTHOR_ORDER) do
if entity.visible then
AUTHOR.looks[entity.look.kind].draw(entity)
end
end
end
-- ===== Input ==================================================================================
--
-- Held state, because rules ask "is this key down" rather than "was it just pressed". The
-- generated game points the engine's callbacks straight at these.
AUTHOR_KEYS = {}
AUTHOR_SWITCHES = {}
function authorKeyDown(keysym, scancode)
AUTHOR_KEYS[scancode] = true
end
function authorKeyUp(keysym, scancode)
AUTHOR_KEYS[scancode] = nil
end
function authorSwitchDown(what)
AUTHOR_SWITCHES[what] = true
end
function authorSwitchUp(what)
AUTHOR_SWITCHES[what] = nil
end