546 lines
16 KiB
Text
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
|