--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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