singe/assets/Forge/Author.singe
2026-09-14 22:32:53 -05:00

4853 lines
150 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 (FORGE.md).
--
-- A game made with Forge is not interpreted: Forge/AuthorCompile.singe turns its description into
-- ordinary Lua, and this file is the library that Lua calls. Rules become real functions, so
-- nothing walks a table every frame, and the result can be opened in ZeroBrane and edited by hand
-- like any other game.
--
-- The nouns (FORGE.md section 3):
--
-- layers which of the engine's own layers the game uses. A genre is only a choice of layers.
-- types what a thing is: a look, behaviours, and vars. Everything placed or spawned is
-- an instance of a type.
-- rooms where things are. A room keeps its state when it is left unless it says reset.
-- rules a trigger, conditions, and actions. Actions that take time run as a coroutine.
--
-- AUTHOR is also the manifest the compiler and the editor read: every layer, look, behaviour,
-- condition, action, and event declares its parameters and the Lua it emits, so a new kind of
-- game is a set of entries here rather than a new release of anything.
--
-- The manifest's emit functions receive their parameters already as Lua source fragments -- the
-- compiler has turned a number into a number, an expression into Lua, an entity name into a lookup
-- -- so an entry only concatenates. The parameter types (FORGE.md section 3):
--
-- number a number, or an expression that makes one
-- expression any expression
-- string text, quoted
-- boolean true or false
-- entity "self", "other", or an entity id
-- type a type's name
-- scancode a key, from SCANCODE
-- switch a switch, from SWITCH_*
-- file a file name
-- state a state name
-- track a track's name
-- room a room's name
-- lua Lua, as written
AUTHOR = {
layers = {},
looks = {},
behaviours = {},
conditions = {},
actions = {},
events = {}
}
AUTHOR_GAME = nil -- The description handed to authorBegin.
AUTHOR_VARS = {} -- Game scope vars: score, lives, flags, whatever the game declared.
AUTHOR_LIVE = {} -- Instances in the current room, in draw order.
AUTHOR_BY_ID = {} -- Live instances by id.
AUTHOR_BY_NODE = {} -- Live instances by node, for what physics and rays report.
AUTHOR_3D = false -- Whether the game has a scene3d layer.
AUTHOR_MOUSE = { dx = 0, dy = 0 } -- Relative motion this frame, for a camera that looks.
AUTHOR_ROOM = nil -- The current room's table.
AUTHOR_STARTED = 0 -- singeGetTicks() when the game began.
AUTHOR_CONTROLS = true -- False while a cut-scene has the controls.
AUTHOR_FONT = nil
AUTHOR_FONT_POINTS = 18
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.
local MOUSE_LEFT = nil -- Filled from SWITCH_BUTTON3 once Framework is in: the gun's trigger.
local SAY_SECONDS_MIN = 1.5 -- A line stays up at least this long,
local SAY_PER_CHAR = 0.05 -- plus this per character.
local SOUND_MAX_VOLUME = 63 -- soundPlay's loudest; Forge volumes are 0 to 100 of it.
local PLATFORMER_STATES = { idle = true, walk = true, jump = true, fall = true } -- The states a platformer sets itself.
local AUTHOR_GROUND = { r = 18, g = 18, b = 26 } -- Behind a flat game with no disc: the editor's own dark.
local SPAWN_MARGIN = 40 -- How far off the overlay a projectile may go before it is dropped.
local FRAME_CAP = 0.1 -- The longest step a frame may take, so a stall does not teleport.
local RAY_REACH = 500 -- How far a gun's ray goes, in world units.
local FAR_AWAY = 300 -- A 3D projectile past this from the origin is dropped.
local LOOK_SPEED = 0.2 -- Degrees of turn per pixel of mouse motion.
local PITCH_LIMIT = 80 -- Degrees a looking camera may tilt.
local models = {} -- Loaded models by file.
local meshes = {} -- Built primitives by description.
local cameraNode = nil -- The node the scene is drawn from, when a camera behaviour has one.
local NAV_SCALE = 0.01 -- Overlay pixels to navigation units in a 2D room: a 720 wide room is 7.2 across.
local NAV_RADIUS_2D = 0.08 -- Agents in a 2D room, in navigation units: eight pixels.
local NAV_HEIGHT_2D = 0.5
local WALK_TIMEOUT = 12 -- Seconds a walkTo waits before giving up on an unreachable point.
local FADE_DEFAULT = 0.6 -- Seconds a fade takes when the rule does not say.
local CHOICE_KEYS = { "MAIN_1", "MAIN_2", "MAIN_3", "MAIN_4", "MAIN_5", "MAIN_6", "MAIN_7", "MAIN_8", "MAIN_9" }
local dialogues = {} -- Compiled dialogues by name, from authorDialogues.
local talking = nil -- The dialogue on screen: { lines, choices, chosen }.
local fadeLevel = 0 -- 0 clear, 1 black.
local parserLayer = nil -- The parser layer, when the game has one.
AUTHOR_TYPING = "" -- What has been typed at the parser's prompt.
local bezel = nil -- The score bezel layer, when the game has one.
local results = nil -- The results card after gameOver: { score, best }.
local masterReady = false -- Whether the master service's script has been loaded.
local BEST_KEY = "forge.best"
local rules = {} -- Compiled rules by event name.
local rulesReady = false -- authorRules has been called; before it, events are kept for it.
local pending = {} -- Events raised before the rules arrived: the first room's start.
local pairsToTest = {} -- Kind pairs the collision rules ask about.
local sequences = {} -- Running coroutines, by rule and instance.
local timers = {} -- Runtime timers: { instance, name, at, every }.
local onceSeen = {} -- once tags, by scope.
local sounds = {} -- Loaded clips by file.
local playing = {} -- What soundPlay is playing, by channel: the file and the instance that played it, for soundDone.
local sprites = {} -- Loaded images by file.
local pointers = {} -- Injected pointer positions by player, for tests.
local pressedNow = {} -- Keys and switches that went down this frame.
local lastTime = 0
local lastFrame = -1
local nextSerial = 1
local hud = nil -- { gui, document, bind }
local sayLine = nil -- { text, until }
local flashLine = nil -- { r, g, b, until, seconds }
local gameOver = false
local music = nil
-- ===== Small helpers ==========================================================================
-- 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_BY_ID[id]
end
-- Every live instance of a type, in draw order.
function authorEach(typeName)
local list = {}
for _, instance in ipairs(AUTHOR_LIVE) do
if instance.alive and (instance.type == typeName) then
list[#list + 1] = instance
end
end
return list
end
function authorCount(typeName)
return #authorEach(typeName)
end
-- Where an instance 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(instance)
local x, y, z = nodeGetPosition(instance.node)
return x, y, z
end
function authorX(instance)
local x = nodeGetPosition(instance.node)
return x
end
function authorY(instance)
local _, y = nodeGetPosition(instance.node)
return y
end
-- An instance's field, for expressions: x, y, z, id, type, state, or one of its vars.
function authorField(instance, name)
if instance == nil then
return nil
end
if name == "x" then
return authorX(instance)
elseif name == "y" then
return authorY(instance)
elseif name == "z" then
local _, _, z = nodeGetPosition(instance.node)
return z
elseif (name == "id") or (name == "type") then
return instance[name]
end
return instance.vars[name]
end
-- The distance between two instances -- either may be given by id -- across the picture in 2D
-- and through the scene in 3D.
function authorDistance(a, b)
if type(a) == "string" then
a = AUTHOR_BY_ID[a]
end
if type(b) == "string" then
b = AUTHOR_BY_ID[b]
end
if (a == nil) or (b == nil) then
return math.huge
end
local ax, ay, az = authorPosition(a)
local bx, by, bz = authorPosition(b)
if AUTHOR_3D then
return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2 + (az - bz) ^ 2)
end
return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2)
end
function authorRandom(low, high)
if high == nil then
return math.random() * (low or 1)
end
return low + math.random() * (high - low)
end
function authorHas(item)
for _, held in ipairs(AUTHOR_VARS.inventory or {}) do
if held == item then
return true
end
end
return false
end
-- How big an instance is, from its look and its own scale. Every look answers.
function authorSize(instance)
local look = AUTHOR.looks[instance.look.kind]
local scale = instance.scale or 1
local w, h, d
if look and look.size then
w, h, d = look.size(instance)
else
w, h, d = instance.look.w or 0, instance.look.h or 0, instance.look.d or 0
end
return w * scale, h * scale, (d or 0) * scale
end
-- The look's own size, before the entity's scale: what a body on the node wants, since bodies
-- take the node's scale themselves.
function authorUnscaledSize(instance)
local w, h, d = authorSize(instance)
local scale = instance.scale or 1
return w / scale, h / scale, d / scale
end
-- An instance's box in overlay coordinates, centred on it, or standing on it when its look says
-- anchor feet, and scaled with depth in a room that scales.
function authorBounds(instance)
local x, y = authorPosition(instance)
local w, h = authorSize(instance)
local scale = authorDepthScaleOf(instance)
w = w * scale
h = h * scale
if instance.look.anchor == "feet" then
return x - w / 2, y - h, w, h
end
return x - w / 2, y - h / 2, w, h
end
-- How big a thing at a height on the picture is drawn, in a room whose scaleBy says things
-- further up the picture are further away. 1 everywhere else.
-- Where an instance sorts in a room that sorts by depth: its y, except that a line of text is a
-- readout and stays in front of everything.
function authorDepthY(instance)
if instance.look.kind == "text" then
return math.huge
end
return authorY(instance)
end
-- The room's depth scale for an instance: what stands on the floor (anchor feet) shrinks with
-- distance; a backdrop, a readout, or a thing floating in the air is drawn as it is.
function authorDepthScaleOf(instance)
if instance.look.anchor ~= "feet" then
return 1
end
return authorDepthScale(select(2, authorPosition(instance)))
end
function authorDepthScale(y)
local table_ = AUTHOR_ROOM and AUTHOR_ROOM.scaleBy or nil
if (table_ == nil) or (#table_ < 2) then
return 1
end
local before, after
for _, key in ipairs(table_) do
if (key.y <= y) and ((before == nil) or (key.y > before.y)) then
before = key
end
if (key.y >= y) and ((after == nil) or (key.y < after.y)) then
after = key
end
end
before = before or after
after = after or before
if (before == after) or (after.y == before.y) then
return before.scale
end
return before.scale + (after.scale - before.scale) * (y - before.y) / (after.y - before.y)
end
-- A key named in a behaviour's parameters, as the engine delivers it. Rules have theirs compiled;
-- behaviours get the description's own text and resolve it here.
local function keyValue(name)
if type(name) == "string" then
return SCANCODE[name] and SCANCODE[name].value or nil
end
return name
end
-- A switch named the same way.
local function switchValue(name)
if type(name) == "string" then
return _G[name]
end
return name
end
-- A picture loaded once per file -- or, with a frame count, as a sheet of that many frames across,
-- which is its own sprite since the engine keeps the frames with the handle.
-- Where a file the description names really is. A game built in the editor names files as the
-- engine sees them from the games directory (Forge/kit/hero.png); a released game carries its
-- files beside itself and names them from there (kit/hero.png), and the compiled script says
-- where it is in AUTHOR_DIR. The name is tried beside the game first, then as it is.
function authorFile(name)
if (AUTHOR_DIR or "") ~= "" and not name:match("^[/\\]") and not name:match("^%a:") then
local probe = io.open(AUTHOR_DIR .. name, "rb")
if probe ~= nil then
probe:close()
return AUTHOR_DIR .. name
end
end
return name
end
local function spriteFor(file, frames)
local key = file .. "#" .. tostring(frames or 1)
if sprites[key] == nil then
if (frames or 1) > 1 then
sprites[key] = spriteLoadFrames(frames, authorFile(file))
else
sprites[key] = spriteLoad(authorFile(file))
end
end
return sprites[key]
end
local function soundFor(file)
if sounds[file] == nil then
sounds[file] = soundLoad(authorFile(file))
end
return sounds[file]
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: sprites, scores, prompts.",
params = {},
begin = function() end
}
AUTHOR.layers.disc = {
help = "The video the game is played over. The file is what the editor scrubs; games.dat names it when the game runs.",
params = { file = "file", start = "number" },
begin = function(layer)
discPlay()
if layer.start then
discSkipToFrame(layer.start)
end
end
}
AUTHOR.layers.hud = {
help = "An RmlUi document over everything. bind maps element ids to game vars, refreshed as they change.",
params = { document = "file", bind = "table" },
begin = function(layer)
local gui = guiNew(overlayGetWidth(), overlayGetHeight())
hud = { gui = gui, document = guiLoad(gui, authorFile(layer.document)), bind = layer.bind or {}, shown = {} }
end
}
AUTHOR.layers.music = {
help = "A track that plays from the start, looping.",
params = { file = "file", volume = "number" },
begin = function(layer)
music = musicLoad(authorFile(layer.file))
if layer.volume then
musicSetVolume(layer.volume)
end
musicPlay(music, -1)
end
}
AUTHOR.layers.scene3d = {
help = "The 3D scene: physics in three axes, a sun, a sky, fog. Positions are world units.",
params = { gravity = "number", sky = "file", ambient = "table", fog = "table", sun = "table", fov = "number", exposure = "number" },
begin = function(layer)
local ok, err = pcall(sceneEnable, true)
if not ok then
debugPrint("Author: the 3D scene could not start: " .. tostring(err))
end
AUTHOR_3D = true
physicsSetEnabled(true)
physicsSet2D(false)
physicsSetGravity(0, layer.gravity or -9.8, 0)
if layer.sky then
sceneSetSky(authorFile(layer.sky))
end
if layer.ambient then
sceneSetAmbient(layer.ambient.r or 40, layer.ambient.g or 40, layer.ambient.b or 50)
end
if layer.fog then
sceneSetFog(layer.fog.r or 0, layer.fog.g or 0, layer.fog.b or 0, layer.fog.near or 30, layer.fog.far or 80)
end
if layer.exposure then
sceneSetExposure(layer.exposure)
end
if layer.sun ~= false then
local sun = layer.sun or {}
local light = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(light, sun.x or -5, sun.y or 10, sun.z or 6)
nodeLookAt(light, 0, 0, 0)
lightSetIntensity(light, sun.intensity or 1.5)
lightSetShadow(light, sun.shadow ~= false)
end
cameraSetPerspective(layer.fov or 60, 0.1, 500)
end
}
AUTHOR.layers.bezel = {
help = "The arcade score panel around the picture, showing the vars score (and score2), lives (and lives2), and credits as they change.",
params = { twin = "boolean" },
begin = function(layer)
bezel = { twin = layer.twin, shown = {} }
scoreBezelEnable(true)
if layer.twin then
scoreBezelTwinScoreOn(true)
end
end
}
AUTHOR.layers.parser = {
help = "A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event.",
params = { prompt = "string", words = "table", ignore = "table" },
begin = function(layer)
parserLayer = layer
keyboardSetMode(MODE_FULL)
end
}
-- ===== Looks ==================================================================================
--
-- How an instance is drawn. A look draws itself and says how big it is; it never knows which
-- layer it is on. The 3D looks build nodes under the instance's own and draw nothing themselves:
-- the scene draws them.
AUTHOR.looks.box = {
help = "A filled rectangle, centred on the instance -- or standing on it, with anchor feet.",
params = { w = "number", h = "number", r = "number", g = "number", b = "number", anchor = "string" },
choices = { anchor = { "feet" } },
size = function(instance) return instance.look.w or 20, instance.look.h or 20 end,
draw = function(instance)
local look = instance.look
local x, y = authorPosition(instance)
local w, h = authorSize(instance)
local scale = authorDepthScaleOf(instance)
local top = (look.anchor == "feet") and (y - h * scale) or (y - h * scale / 2)
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(top), math.floor(top + h * scale) do
overlayLine(x - w * scale / 2, row, x + w * scale / 2, row)
end
end
}
AUTHOR.looks.sprite = {
help = "An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both.",
params = { file = "file", frames = "number", anchor = "string", faces = "string" },
choices = { anchor = { "feet" }, faces = { "right", "left", "none" } },
load = function(instance)
instance.sprite = spriteFor(instance.look.file, instance.look.frames)
end,
size = function(instance)
if instance.sprite == nil then
return 0, 0
end
if (instance.look.frames or 1) > 1 then
return spriteFrameWidth(instance.sprite), spriteFrameHeight(instance.sprite)
end
return spriteGetWidth(instance.sprite), spriteGetHeight(instance.sprite)
end,
draw = function(instance)
local x, y = authorPosition(instance)
local w, h = authorSize(instance)
local scale = authorDepthScaleOf(instance) * (instance.scale or 1)
local top = (instance.look.anchor == "feet") and (y - h * scale) or (y - h * scale / 2)
local frames = instance.look.frames or 1
local frame = instance.vars.frame or 1
local angle = instance.vars.angle or 0
local mirror = authorMirrored(instance)
if (angle ~= 0) or mirror then
-- A turned or mirrored sprite gets an image of its own, so it does not turn every other
-- instance drawn from the same file, and the image is rebuilt only when something
-- about it changes.
local own = instance.ownSprite
if own == nil then
own = (frames > 1) and spriteLoadFrames(frames, authorFile(instance.look.file)) or spriteLoad(authorFile(instance.look.file))
instance.ownSprite = own
end
spriteFlip(own, mirror, false)
if frames > 1 then
if (instance.ownAngle ~= angle) or (instance.ownFrame ~= frame) then
spriteRotateFrame(own, angle, frame)
instance.ownAngle = angle
instance.ownFrame = frame
end
spriteDrawRotatedFrame(own, x, top + h * scale / 2, scale)
else
spriteRotate(own, angle)
spriteScale(own, scale)
spriteDraw(own, x, top + h * scale / 2, true)
end
elseif (frames > 1) or (scale ~= 1) then
spriteDrawFrame(instance.sprite, x - w * scale / 2, top, frame, scale)
else
spriteDraw(instance.sprite, x - w / 2, top)
end
end
}
AUTHOR.looks.particles = {
help = "A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them.",
params = { r = "number", g = "number", b = "number", rate = "number", size = "number", speed = "number", life = "number", spread = "number", gravity = "number" },
load = function(instance)
local look = instance.look
instance.emitter = AUTHOR_3D and emitterNew(instance.node) or emitterNew()
emitterSetColor(instance.emitter, look.r or 255, look.g or 200, look.b or 80, 255, look.r or 255, look.g or 200, look.b or 80, 0)
emitterSetRate(instance.emitter, look.rate or 40)
emitterSetLife(instance.emitter, (look.life or 1) * 0.6, look.life or 1)
emitterSetSpread(instance.emitter, look.spread or 30)
if look.size then
emitterSetSize(instance.emitter, look.size, look.size * 0.2)
end
if look.speed then
emitterSetSpeed(instance.emitter, look.speed * 0.6, look.speed)
end
if look.gravity then
emitterSetGravity(instance.emitter, 0, look.gravity, 0)
end
emitterStart(instance.emitter)
end,
size = function(instance) return instance.look.size or 16, instance.look.size or 16, instance.look.size or 16 end,
draw = function(instance)
if not AUTHOR_3D then
local x, y = authorPosition(instance)
emitterSetPosition(instance.emitter, x, y)
end
end
}
AUTHOR.looks.text = {
help = "A line of text, its top left at the instance. The var text is what it says.",
params = { text = "string", r = "number", g = "number", b = "number" },
load = function(instance)
instance.vars.text = instance.vars.text or instance.look.text or ""
end,
size = function(instance)
-- Measured once per change: rendering text to measure it is not free.
if instance.measured ~= instance.vars.text then
local sprite = fontToSprite(instance.vars.text ~= "" and instance.vars.text or " ")
instance.textW = spriteGetWidth(sprite)
instance.textH = spriteGetHeight(sprite)
instance.measured = instance.vars.text
spriteUnload(sprite)
end
return instance.textW, instance.textH
end,
draw = function(instance)
local x, y = authorPosition(instance)
colorForeground(instance.look.r or 255, instance.look.g or 255, instance.look.b or 255, 255)
if instance.vars.text ~= "" then
fontPrint(x, y, instance.vars.text)
end
end
}
AUTHOR.looks.none = {
help = "Nothing drawn: a trigger, a spawner, a hitbox over video, a camera.",
params = { w = "number", h = "number", d = "number" },
size = function(instance) return instance.look.w or 0, instance.look.h or 0, instance.look.d or 0 end,
draw = function() end
}
local function modelFor(file)
if models[file] == nil then
models[file] = modelLoad(authorFile(file))
end
return models[file]
end
-- Every node under a model instance is the instance's, so a ray or a contact that reports a mesh
-- node or a bone finds its way back without asking the engine for parents (which would end the
-- game on a node a rule had already deleted).
local function claimNodes(instance, node)
AUTHOR_BY_NODE[node] = instance
instance.nodes[#instance.nodes + 1] = node
for _, child in ipairs(nodeGetChildren(node) or {}) do
claimNodes(instance, child)
end
end
AUTHOR.looks.model = {
help = "A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as.",
params = { file = "file", scale = "number", clip = "string", rx = "number", ry = "number", rz = "number", w = "number", h = "number", d = "number" },
load = function(instance)
local look = instance.look
instance.model = modelInstance(modelFor(look.file))
claimNodes(instance, instance.model)
nodeSetParent(instance.model, instance.node)
nodeSetScale(instance.model, look.scale or 1)
nodeSetRotation(instance.model, look.rx or 0, look.ry or 0, look.rz or 0)
if look.clip then
animationPlay(instance.model, look.clip, true)
end
end,
size = function(instance) return instance.look.w or 1, instance.look.h or 1, instance.look.d or 1 end,
draw = function() end
}
-- A primitive mesh, shared by every instance that asks for the same one.
local function meshFor(look)
local key = table.concat({ look.shape or "box", look.w or 1, look.h or 1, look.d or 1 }, "/")
if meshes[key] == nil then
if look.shape == "sphere" then
meshes[key] = meshSphere((look.w or 1) / 2, 24)
elseif look.shape == "cylinder" then
meshes[key] = meshCylinder((look.w or 1) / 2, look.h or 1, 24)
elseif look.shape == "plane" then
meshes[key] = meshPlane(look.w or 1, look.d or 1)
else
meshes[key] = meshBox(look.w or 1, look.h or 1, look.d or 1)
end
end
return meshes[key]
end
AUTHOR.looks.mesh = {
help = "A box, sphere, cylinder, or plane with a colour or a texture. Floors, walls, crates, targets. An occluder is not seen but hides what is behind it: a pillar that is only in the painting.",
params = { shape = "string", w = "number", h = "number", d = "number", r = "number", g = "number", b = "number", texture = "file", unlit = "boolean", metallic = "number", roughness = "number", occluder = "boolean" },
choices = { shape = { "box", "sphere", "cylinder", "plane" } },
load = function(instance)
local look = instance.look
local material = materialNew()
materialSetColor(material, look.r or 200, look.g or 200, look.b or 200)
-- A KTX2 texture goes straight on; any other picture is loaded as a sprite first, which is
-- what materialSetTexture takes for those.
if look.texture and look.texture ~= "" then
if look.texture:lower():match("%.ktx2$") then
materialSetTexture(material, authorFile(look.texture))
else
materialSetTexture(material, spriteFor(look.texture))
end
end
if look.unlit then
materialSetUnlit(material, true)
end
if look.metallic then
materialSetMetallic(material, look.metallic)
end
if look.roughness then
materialSetRoughness(material, look.roughness)
end
-- The editor's preview keeps an occluder visible, so it can be placed; the game hides it.
if look.occluder and not instance.preview then
materialSetOccluder(material, true)
end
instance.material = material
nodeSetMesh(instance.node, meshFor(look), material)
end,
size = function(instance)
local look = instance.look
if look.shape == "plane" then
return look.w or 1, 0.01, look.d or 1
end
return look.w or 1, (look.shape == "sphere") and (look.w or 1) or (look.h or 1), (look.shape == "sphere") and (look.w or 1) or (look.d or 1)
end,
draw = function() end
}
AUTHOR.looks.light = {
help = "A point or spot light, or a sun, on the instance.",
params = { type = "string", r = "number", g = "number", b = "number", intensity = "number", range = "number", shadow = "boolean" },
choices = { type = { "point", "spot", "sun" } },
load = function(instance)
local look = instance.look
local kind = (look.type == "spot") and LIGHT_SPOT or ((look.type == "sun") and LIGHT_DIRECTIONAL or LIGHT_POINT)
local light = lightNew(kind, instance.node)
lightSetColor(light, look.r or 255, look.g or 255, look.b or 255)
lightSetIntensity(light, look.intensity or 1)
if look.range then
lightSetRange(light, look.range)
end
if look.shadow then
lightSetShadow(light, true)
end
instance.light = light
end,
size = function() return 0.5, 0.5, 0.5 end,
draw = function() end
}
AUTHOR.looks.billboard = {
help = "A sprite in the scene, always facing the camera: a tree, a puff, a health bar.",
params = { file = "file", height = "number", w = "number", h = "number", d = "number" },
load = function(instance)
instance.sprite = spriteFor(instance.look.file)
nodeSetSprite(instance.node, instance.sprite, instance.look.height or 1)
nodeSetBillboard(instance.node, BILLBOARD_Y)
end,
size = function(instance) return instance.look.w or 1, instance.look.h or instance.look.height or 1, instance.look.d or 1 end,
draw = function() end
}
AUTHOR.looks.text3d = {
help = "A line of text standing in the scene. The var text is what it says.",
params = { text = "string", height = "number" },
load = function(instance)
instance.vars.text = instance.vars.text or instance.look.text or ""
instance.shown = instance.vars.text
nodeSetText(instance.node, instance.vars.text, instance.look.height or 0.5)
nodeSetBillboard(instance.node, BILLBOARD_Y)
end,
size = function(instance) return 1, instance.look.height or 0.5, 0.1 end,
draw = function(instance)
if instance.shown ~= instance.vars.text then
instance.shown = instance.vars.text
nodeSetText(instance.node, instance.vars.text, instance.look.height or 0.5)
end
end
}
AUTHOR.looks.grid = {
help = "A map of tiles from a sheet, its top left at the instance: columns is the sheet's width in tiles, tile the tile's size, map the rows of tile numbers (1 is the first; 0 is empty), rows separated by semicolons.",
params = { file = "file", tile = "number", columns = "number", map = "string" },
load = function(instance)
local look = instance.look
instance.sprite = spriteFor(look.file)
instance.rows = {}
for row in string.gmatch(tostring(look.map or ""), "[^;]+") do
local cells = {}
for number in string.gmatch(row, "%d+") do
cells[#cells + 1] = tonumber(number)
end
instance.rows[#instance.rows + 1] = cells
end
end,
size = function(instance)
local tile = instance.look.tile or 32
local width = 0
for _, row in ipairs(instance.rows or {}) do
width = math.max(width, #row)
end
return width * tile, #(instance.rows or {}) * tile
end,
draw = function(instance)
local look = instance.look
local tile = look.tile or 32
local columns = look.columns or 1
local x, y = authorPosition(instance)
for r, row in ipairs(instance.rows) do
for c, cell in ipairs(row) do
if cell > 0 then
local sx = ((cell - 1) % columns) * tile
local sy = math.floor((cell - 1) / columns) * tile
spriteDrawGrid(instance.sprite, x + (c - 1) * tile, y + (r - 1) * tile, sx, sy, tile, tile)
end
end
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. attach runs once when
-- the instance is made, step every frame with the seconds since the last one, and on(event) when
-- something happens to the instance.
AUTHOR.behaviours.solid = {
help = "Immovable ground or a wall, in a world2d or a scene3d room. With moving, a body that follows wherever the instance is moved -- a paddle, a lift.",
params = { moving = "boolean" },
attach = function(instance, params)
local w, h, d = authorUnscaledSize(instance)
local kind = params.moving and BODY_KINEMATIC or BODY_STATIC
if AUTHOR_3D then
bodyNew(instance.node, kind, SHAPE_BOX, w, h, d)
else
bodyNew(instance.node, kind, SHAPE_BOX, w, h, w)
end
instance.hasBody = true
end
}
AUTHOR.behaviours.body = {
help = "A thing physics moves: a crate, a ball, debris. Mass, bounce, friction, and buoyancy as Jolt takes them.",
params = { shape = "string", mass = "number", bounce = "number", friction = "number", buoyancy = "number" },
choices = { shape = { "box", "sphere" } },
attach = function(instance, params)
local w, h, d = authorUnscaledSize(instance)
if params.shape == "sphere" then
bodyNew(instance.node, BODY_DYNAMIC, SHAPE_SPHERE, w / 2)
elseif AUTHOR_3D then
bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, d)
else
bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, w)
end
if params.mass then
bodySetMass(instance.node, params.mass)
end
if params.bounce then
bodySetBounce(instance.node, params.bounce)
end
if params.friction then
bodySetFriction(instance.node, params.friction)
end
if params.buoyancy then
bodySetBuoyancy(instance.node, params.buoyancy)
end
instance.hasBody = true
end
}
AUTHOR.behaviours.trigger = {
help = "A volume that reports what enters and leaves it, as the enter and leave events. A checkpoint, a doorway, a prize.",
params = {},
attach = function(instance)
local w, h, d = authorUnscaledSize(instance)
if AUTHOR_3D then
bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w, h, d)
else
bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w, h, w)
end
bodySetTrigger(instance.node, true)
instance.hasBody = true
end
}
AUTHOR.behaviours.character = {
help = "Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera.",
params = { speed = "number", jump = "number", radius = "number", height = "number" },
attach = function(instance, params)
local w, h = authorSize(instance)
instance.speed = params.speed or 4
instance.jump = params.jump or 6
instance.player = true
playerNew(instance.node, SHAPE_CAPSULE, params.radius or (w * PLAYER_RADIUS), params.height or h)
end,
step = function(instance)
local dx = instance.vars.dx or 0
local dy = instance.vars.dy or 0
local fx, fz, rx, rz = authorCameraAxes()
local vx = (rx * dx - fx * dy) * instance.speed
local vz = (rz * dx - fz * dy) * instance.speed
playerMove(instance.node, vx, vz)
if ((dx ~= 0) or (dy ~= 0)) and instance.model then
local x, y, z = authorPosition(instance)
nodeLookAt(instance.model, x - vx, y, z - vz)
end
end
}
AUTHOR.behaviours.seek = {
help = "Moves straight toward a target -- an entity's id, or \"camera\" -- and raises arrived within stopAt of it.",
params = { target = "string", speed = "number", stopAt = "number", fly = "boolean" },
attach = function(instance, params)
instance.seek = params
end,
step = function(instance, dt)
local p = instance.seek
local tx, ty, tz = authorTargetPosition(p.target)
if tx == nil then
return
end
local x, y, z = authorPosition(instance)
local dx, dy, dz = tx - x, ty - y, tz - z
if not p.fly then
dy = 0
end
local far = math.sqrt(dx * dx + dy * dy + dz * dz)
if far <= (p.stopAt or 1) then
if not instance.arrived then
instance.arrived = true
authorEvent("arrived", { self = instance })
end
return
end
instance.arrived = false
local step = math.min((p.speed or 1) * dt, far)
nodeSetPosition(instance.node, x + dx / far * step, y + dy / far * step, z + dz / far * step)
if instance.model then
nodeLookAt(instance.model, x - dx, y, z - dz)
end
end
}
AUTHOR.behaviours.animator = {
help = "A clip per state: idle, walk, attack, hit, dead, or any state a rule sets. A clip that does not loop raises animationDone when it ends.",
params = { idle = "string", walk = "string", attack = "string", hit = "string", dead = "string", fade = "number", loop = "string" },
attach = function(instance, params)
instance.animator = params
instance.playing = nil
end,
step = function(instance)
local p = instance.animator
local state = instance.vars.state
local clip = p[state]
if instance.model == nil then
return
end
if (clip ~= nil) and (clip ~= instance.playing) then
local loops = (state == "idle") or (state == "walk") or (state == "run") or ((p.loop or ""):find(state, 1, true) ~= nil)
animationPlay(instance.model, clip, loops, 1, p.fade or 0.15)
instance.playing = clip
instance.watch = not loops
elseif instance.watch and not animationIsPlaying(instance.model) then
instance.watch = false
authorEvent("animationDone", { self = instance, state = state })
end
end
}
AUTHOR.behaviours.camera = {
help = "The scene is drawn from this instance. fixed looks at a point; follow keeps an offset from a target; first sits on a target's eyes and the mouse looks; orbit circles a target; rail rides a track of points with stops.",
params = { mode = "string", target = "string", x = "number", y = "number", z = "number", lookX = "number", lookY = "number", lookZ = "number", track = "track", lag = "number", eye = "number" },
choices = { mode = { "fixed", "follow", "first", "orbit", "rail" } },
attach = function(instance, params)
instance.camera = params
instance.yaw = 0
instance.pitch = 0
cameraNode = instance.node
cameraSet(instance.node)
if params.mode == "rail" then
instance.rail = { t = 0, waiting = nil, passed = {} }
end
if (params.mode == "fixed") or (params.mode == nil) then
nodeLookAt(instance.node, params.lookX or 0, params.lookY or 0, params.lookZ or 0)
end
end,
step = function(instance, dt)
local p = instance.camera
if p.mode == "follow" then
local tx, ty, tz = authorTargetPosition(p.target)
if tx then
local x, y, z = authorPosition(instance)
local k = (p.lag and p.lag > 0) and math.min(1, dt / p.lag) or 1
nodeSetPosition(instance.node, x + (tx + (p.x or 0) - x) * k, y + (ty + (p.y or 3) - y) * k, z + (tz + (p.z or 6) - z) * k)
nodeLookAt(instance.node, tx, ty + (p.lookY or 0.5), tz)
end
elseif p.mode == "orbit" then
local tx, ty, tz = authorTargetPosition(p.target)
if tx then
instance.yaw = instance.yaw - AUTHOR_MOUSE.dx * LOOK_SPEED
instance.pitch = math.max(-PITCH_LIMIT, math.min(PITCH_LIMIT, instance.pitch + AUTHOR_MOUSE.dy * LOOK_SPEED))
local far = p.z or 6
local yaw = math.rad(instance.yaw)
local pitch = math.rad(instance.pitch)
nodeSetPosition(instance.node, tx + math.sin(yaw) * math.cos(pitch) * far, ty + (p.y or 1) + math.sin(pitch) * far, tz + math.cos(yaw) * math.cos(pitch) * far)
nodeLookAt(instance.node, tx, ty + (p.lookY or 0.5), tz)
end
elseif p.mode == "first" then
local target = authorTargetInstance(p.target)
if target then
local tx, ty, tz = authorPosition(target)
instance.yaw = instance.yaw - AUTHOR_MOUSE.dx * LOOK_SPEED
instance.pitch = math.max(-PITCH_LIMIT, math.min(PITCH_LIMIT, instance.pitch - AUTHOR_MOUSE.dy * LOOK_SPEED))
nodeSetPosition(instance.node, tx, ty + (p.eye or 0.7), tz)
nodeSetRotation(instance.node, instance.pitch, instance.yaw, 0)
end
elseif p.mode == "rail" then
authorRailStep(instance, dt)
end
end
}
AUTHOR.behaviours.platformer = {
help = "Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. Its point is its feet, so its look stands on it. Sets the var facing, and the var state to idle, walk, jump, or fall unless a rule set another.",
params = { speed = "number", jump = "number" },
attach = function(instance, params)
local w, h = authorSize(instance)
instance.speed = params.speed or 200
instance.jump = params.jump or 500
instance.player = true
-- A player stands on its node, so the node is the feet; the look is drawn to match.
instance.look.anchor = "feet"
playerNew(instance.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h)
end,
step = function(instance)
local drive = instance.vars.drive
local state = instance.vars.state
-- The rules say which way; this clears it each frame so releasing a key stops the run.
playerMove(instance.node, drive * instance.speed)
instance.vars.drive = 0
-- Which way it faces and what it is doing, for a sprite look and a frames behaviour: a
-- state a rule set (hit, dead) is left alone until the rule sets another.
if drive ~= 0 then
instance.vars.facing = (drive < 0) and "left" or "right"
end
if (state == nil) or PLATFORMER_STATES[state] then
if not playerIsOnGround(instance.node) then
local _, vy = playerGetVelocity(instance.node)
instance.vars.state = (vy < 0) and "jump" or "fall"
else
instance.vars.state = (drive ~= 0) and "walk" or "idle"
end
end
end
}
AUTHOR.behaviours.drift = {
help = "Moves steadily, for a cloud, a platform, or a target.",
params = { vx = "number", vy = "number" },
attach = function(instance, params)
instance.vars.vx = instance.vars.vx or params.vx or 0
instance.vars.vy = instance.vars.vy or params.vy or 0
end,
step = function(instance, dt)
local x, y, z = authorPosition(instance)
nodeSetPosition(instance.node, x + instance.vars.vx * dt, y + instance.vars.vy * dt, z)
end
}
AUTHOR.behaviours.keys = {
help = "Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware.",
params = { player = "number", left = "scancode", right = "scancode", up = "scancode", down = "scancode", fire = "scancode" },
attach = function(instance, params)
instance.keys = {
left = keyValue(params.left),
right = keyValue(params.right),
up = keyValue(params.up),
down = keyValue(params.down),
fire = keyValue(params.fire)
}
instance.vars.dx = 0
instance.vars.dy = 0
end,
step = function(instance)
local k = instance.keys
local dx = 0
local dy = 0
if AUTHOR_CONTROLS then
if k.left and authorKeyHeld(k.left) then dx = dx - 1 end
if k.right and authorKeyHeld(k.right) then dx = dx + 1 end
if k.up and authorKeyHeld(k.up) then dy = dy - 1 end
if k.down and authorKeyHeld(k.down) then dy = dy + 1 end
end
instance.vars.dx = dx
instance.vars.dy = dy
instance.vars.fire = AUTHOR_CONTROLS and (k.fire ~= nil) and authorKeyHeld(k.fire)
end
}
AUTHOR.behaviours.mover = {
help = "Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.",
params = { speed = "number", clamp = "boolean" },
attach = function(instance, params)
instance.moveSpeed = params.speed or 200
instance.clamp = params.clamp
end,
step = function(instance, dt)
local x, y, z = authorPosition(instance)
local w, h = authorSize(instance)
x = x + (instance.vars.dx or 0) * instance.moveSpeed * dt
y = y + (instance.vars.dy or 0) * instance.moveSpeed * dt
if (instance.vars.dx or 0) ~= 0 then
instance.vars.facing = (instance.vars.dx < 0) and "left" or "right"
end
if instance.clamp then
x = math.max(w / 2, math.min(overlayGetWidth() - w / 2, x))
y = math.max(h / 2, math.min(overlayGetHeight() - h / 2, y))
end
nodeSetPosition(instance.node, x, y, z)
end
}
AUTHOR.behaviours.shooter = {
help = "Spawns a type from an offset while the var fire is set (or always, with auto), no faster than the rate.",
params = { spawns = "type", rate = "number", offsetX = "number", offsetY = "number", auto = "boolean" },
attach = function(instance, params)
instance.shooter = params
instance.lastShot = -math.huge
end,
step = function(instance)
local p = instance.shooter
if (p.auto or instance.vars.fire) and (authorTime() - instance.lastShot >= (p.rate or 0.2)) then
local x, y = authorPosition(instance)
instance.lastShot = authorTime()
authorSpawn(p.spawns, x + (p.offsetX or 0), y + (p.offsetY or 0), 0, { owner = instance.id })
end
end
}
AUTHOR.behaviours.projectile = {
help = "Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies.",
params = { vx = "number", vy = "number", vz = "number", life = "number", aim = "boolean" },
attach = function(instance, params)
instance.vars.vx = instance.vars.vx or params.vx or 0
instance.vars.vy = instance.vars.vy or params.vy or (AUTHOR_3D and 0 or -400)
instance.vars.vz = instance.vars.vz or params.vz or 0
instance.dies = authorTime() + (params.life or 5)
if params.aim and not AUTHOR_3D then
instance.vars.angle = math.deg(math.atan(instance.vars.vx, -instance.vars.vy))
end
end,
step = function(instance, dt)
local x, y, z = authorPosition(instance)
x = x + instance.vars.vx * dt
y = y + instance.vars.vy * dt
z = z + instance.vars.vz * dt
nodeSetPosition(instance.node, x, y, z)
if authorTime() > instance.dies then
authorDestroy(instance)
elseif AUTHOR_3D then
if (math.abs(x) > FAR_AWAY) or (math.abs(y) > FAR_AWAY) or (math.abs(z) > FAR_AWAY) then
authorDestroy(instance)
end
elseif (x < -SPAWN_MARGIN) or (y < -SPAWN_MARGIN) or (x > overlayGetWidth() + SPAWN_MARGIN) or (y > overlayGetHeight() + SPAWN_MARGIN) then
authorDestroy(instance)
end
end
}
AUTHOR.behaviours.health = {
help = "The var health; the damage action lowers it, and at zero the instance raises death and is destroyed unless keep is set. With ragdoll, a model falls limp instead and goes after linger seconds.",
params = { max = "number", keep = "boolean", ragdoll = "boolean", linger = "number" },
attach = function(instance, params)
instance.vars.health = instance.vars.health or params.max or 1
instance.keepDead = params.keep or params.ragdoll
instance.linger = params.linger or 4
if params.ragdoll and instance.model then
ragdollNew(instance.model)
instance.ragdoll = true
end
end,
on = function(instance, event, data)
if (event == "death") and instance.ragdoll then
ragdollActivate(instance.model)
authorTimerStart(instance, "gone", instance.linger)
instance.isTarget = false
elseif (event == "timer") and (data.name == "gone") then
authorDestroy(instance)
end
end
}
AUTHOR.behaviours.timer = {
help = "Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.",
params = { name = "string", after = "number", every = "number", start = "boolean" },
attach = function(instance, params)
if params.start ~= false then
authorTimerStart(instance, params.name, params.after or params.every, params.every)
end
end
}
AUTHOR.behaviours.spawner = {
help = "Makes instances of a type every so many seconds at its own position or at one of its points, up to max alive at once and total in all.",
params = { spawns = "type", every = "number", max = "number", total = "number", points = "table", pick = "string" },
choices = { pick = { "turn", "random" } },
attach = function(instance, params)
instance.spawner = params
instance.nextAt = authorTime() + (params.every or 1)
instance.vars.made = 0 -- A var, so a rule can ask how many it has made.
instance.point = 0
end,
step = function(instance)
local p = instance.spawner
if (authorTime() < instance.nextAt) or (p.total and instance.vars.made >= p.total) then
return
end
if p.max and (authorCount(p.spawns) >= p.max) then
return
end
local x, y, z = authorPosition(instance)
if p.points and #p.points > 0 then
local at
if p.pick == "random" then
at = math.random(#p.points)
else
instance.point = (instance.point % #p.points) + 1
at = instance.point
end
x = p.points[at].x or p.points[at][1]
y = p.points[at].y or p.points[at][2]
z = p.points[at].z or p.points[at][3] or z
end
instance.nextAt = authorTime() + (p.every or 1)
instance.vars.made = instance.vars.made + 1
authorSpawn(p.spawns, x, y, z, { spawner = instance.id })
end
}
AUTHOR.behaviours.sound = {
help = "A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's.",
params = { spawn = "file", hit = "file", death = "file", pressed = "file", volume = "number" },
attach = function(instance, params)
instance.sounds = params
if params.spawn then
authorPlaySound(params.spawn, params.volume, instance)
end
end,
on = function(instance, event)
local file = instance.sounds[event]
if file and (event ~= "spawn") then
authorPlaySound(file, instance.sounds.volume, instance)
end
end
}
AUTHOR.behaviours.gun = {
help = "The pointer as a gun for one player: a press of the trigger raises hit on what is under it, or miss. Ammo counts down; a shot off the screen reloads. With aim centre it fires from the middle of the picture -- a first-person gun.",
params = { player = "number", trigger = "switch", ammo = "number", reload = "string", aim = "string" },
choices = { reload = { "offscreen" }, aim = { "centre" } },
attach = function(instance, params)
instance.gun = params
instance.trigger = switchValue(params.trigger)
instance.vars.ammo = instance.vars.ammo or params.ammo or 6
instance.vars.player = params.player or 1
end
}
AUTHOR.behaviours.target = {
help = "Something a gun can hit. In 2D its look's box; in 3D a body the size of its look, plus zones -- boxes on named bones, so a shot says which part it hit.",
params = { zones = "table" },
attach = function(instance, params)
instance.isTarget = true
if AUTHOR_3D and not instance.hasBody then
-- The body sits on a child node raised by half the height, since a model stands on its
-- origin and a box centred on the feet would be half underground.
local w, h, d = authorSize(instance)
local box = nodeNew()
nodeSetParent(box, instance.node)
nodeSetPosition(box, 0, h / 2, 0)
nodeSetName(box, "body")
bodyNew(box, BODY_KINEMATIC, SHAPE_BOX, w, h, d)
bodySetTrigger(box, true)
AUTHOR_BY_NODE[box] = instance
instance.nodes[#instance.nodes + 1] = box
end
for _, zone in ipairs(params.zones or {}) do
local bone = instance.model and nodeFind(zone.bone, instance.model) or nil
if bone then
local box = nodeNew()
nodeSetParent(box, bone)
nodeSetName(box, zone.name)
AUTHOR_BY_NODE[box] = instance
instance.nodes[#instance.nodes + 1] = box
instance.zones = instance.zones or {}
instance.zones[#instance.zones + 1] = { node = box, name = zone.name, reach = math.max(zone.w or 0.3, zone.h or 0.3, zone.d or 0.3) / 2 }
else
debugPrint("Author: " .. instance.id .. " has no bone called '" .. tostring(zone.bone) .. "' for zone " .. tostring(zone.name))
end
end
end
}
AUTHOR.behaviours.walker = {
help = "Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes.",
params = { speed = "number", radius = "number", follow = "string", every = "number", stopAt = "number" },
attach = function(instance, params)
instance.walker = params
instance.repath = 0
end,
step = function(instance)
local nav = authorRoomNav()
if nav == nil then
return
end
if instance.agent == nil then
local x, y, z = authorPosition(instance)
if AUTHOR_3D then
instance.navNode = instance.node
else
instance.navNode = nodeNew()
nodeSetPosition(instance.navNode, x * NAV_SCALE, 0, y * NAV_SCALE)
end
instance.agent = navAgentNew(nav.nav, instance.navNode, instance.walker.radius or nav.radius, nav.height, (instance.walker.speed or 120) * nav.scale)
AUTHOR_AGENTS[instance.agent] = instance
end
if not AUTHOR_3D then
local nx, _, nz = nodeGetPosition(instance.navNode)
nodeSetPosition(instance.node, nx / NAV_SCALE, nz / NAV_SCALE, 0)
end
-- Following: the target's position is asked for again every so often, and the walk ends
-- within stopAt of it, where a seek would.
if instance.walker.follow and (authorTime() >= instance.repath) then
local tx, ty, tz = authorTargetPosition(instance.walker.follow)
instance.repath = authorTime() + (instance.walker.every or 0.5)
if tx then
local x, y, z = authorPosition(instance)
local far = AUTHOR_3D and math.sqrt((tx - x) ^ 2 + (tz - z) ^ 2) or math.sqrt((tx - x) ^ 2 + (ty - y) ^ 2)
if far > (instance.walker.stopAt or 1.5) then
authorWalkStart(instance, tx, ty, tz)
elseif instance.walking then
navAgentStop(instance.agent)
instance.walking = false
instance.vars.state = "idle"
authorEvent("arrived", { self = instance })
end
end
end
if instance.walking then
if navAgentIsArrived(instance.agent) then
instance.walking = false
instance.vars.state = "idle"
authorEvent("arrived", { self = instance })
else
local vx, vy, vz = navAgentGetVelocity(instance.agent)
if (vx ~= 0) or (vz ~= 0) then
instance.vars.state = "walk"
if instance.model then
local x, y, z = authorPosition(instance)
nodeLookAt(instance.model, x - vx, y, z - vz)
end
instance.vars.facing = (vx < 0) and "left" or "right"
end
end
end
end
}
AUTHOR.behaviours.patrol = {
help = "Walks a list of points in turn -- a creep's lane, a guard's round -- looping or stopping, and raises patrolEnd at the last.",
params = { points = "table", loop = "boolean" },
attach = function(instance, params)
instance.patrol = params
instance.leg = 0
end,
step = function(instance)
local p = instance.patrol
if (instance.agent == nil) or instance.walking or (p.points == nil) or (#p.points == 0) then
return
end
if instance.leg >= #p.points then
if p.loop then
instance.leg = 0
elseif not instance.patrolDone then
instance.patrolDone = true
authorEvent("patrolEnd", { self = instance })
return
else
return
end
end
instance.leg = instance.leg + 1
local point = p.points[instance.leg]
authorWalkStart(instance, point.x or point[1], point.y or point[2] or 0, point.z or point[3] or 0)
end
}
AUTHOR.behaviours.vehicle = {
help = "A car, motorcycle, tank, or boat on the engine's vehicle physics, driven by the vars dy (throttle, up is forward) and dx (steering) from keys. Wheels hang at the four corners the sizes say; a boat needs none.",
params = { type = "string", wheelX = "number", wheelZ = "number", radius = "number", width = "number", suspension = "number", torque = "number", mass = "number", thrust = "number" },
choices = { type = { "car", "motorcycle", "tank", "boat" } },
attach = function(instance, params)
local w, h, d = authorSize(instance)
local kind = ({ car = VEHICLE_CAR, motorcycle = VEHICLE_MOTORCYCLE, tank = VEHICLE_TANK, boat = VEHICLE_BOAT })[params.type or "car"] or VEHICLE_CAR
if not instance.hasBody then
bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, d)
instance.hasBody = true
end
bodySetMass(instance.node, params.mass or 800)
vehicleNew(instance.node, kind)
if kind ~= VEHICLE_BOAT then
local wx = params.wheelX or (w / 2)
local wz = params.wheelZ or (d / 2 - 0.2)
local radius = params.radius or 0.35
local wheels = (kind == VEHICLE_MOTORCYCLE) and { { 0, -wz }, { 0, wz } } or { { -wx, -wz }, { wx, -wz }, { -wx, wz }, { wx, wz } }
for _, at in ipairs(wheels) do
local wheel = nodeNew()
nodeSetParent(wheel, instance.node)
nodeSetPosition(wheel, at[1], -h / 2, at[2])
instance.nodes[#instance.nodes + 1] = wheel
vehicleAddWheel(instance.node, wheel, radius, params.width or 0.2, params.suspension or 0.3)
end
if params.torque then
vehicleSetEngine(instance.node, params.torque, 6000)
end
elseif params.thrust then
vehicleSetThrust(instance.node, params.thrust, 0, -0.2, d / 2)
end
instance.vehicle = true
end,
step = function(instance)
local dx = instance.vars.dx or 0
local dy = instance.vars.dy or 0
vehicleDrive(instance.node, -dy, dx, instance.vars.brake or 0)
instance.vars.speed = vehicleGetSpeed(instance.node)
end
}
AUTHOR.behaviours.racer = {
help = "Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.",
params = { track = "track", throttle = "number", reach = "number" },
attach = function(instance, params)
instance.racer = params
instance.leg = 1
end,
step = function(instance)
local track = authorTrackNamed(instance.racer.track or "")
local points = track and track.points or {}
if (#points == 0) or not instance.vehicle then
return
end
local point = points[instance.leg]
local x, y, z = authorPosition(instance)
local dx, dz = (point.x or 0) - x, (point.z or 0) - z
local far = math.sqrt(dx * dx + dz * dz)
if far < (instance.racer.reach or 3) then
instance.leg = (instance.leg % #points) + 1
return
end
-- The chassis' heading from its yaw; the steering is the signed angle to the point.
local _, yaw, _ = nodeGetRotation(instance.node)
local heading = math.rad(yaw)
local fx, fz = -math.sin(heading), -math.cos(heading)
local cross = fx * dz - fz * dx
local dot = fx * dx + fz * dz
local angle = math.atan(cross, dot)
instance.vars.dx = math.max(-1, math.min(1, -angle * 2))
instance.vars.dy = -(instance.racer.throttle or 0.6) * ((dot > 0) and 1 or 0.3)
end
}
AUTHOR.behaviours.thrust = {
help = "A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.",
params = { force = "number", turn = "number" },
attach = function(instance, params)
instance.thrust = params
end,
step = function(instance)
if not instance.hasBody then
return
end
local _, yaw, _ = nodeGetRotation(instance.node)
local heading = math.rad(yaw)
local push = -(instance.vars.dy or 0) * (instance.thrust.force or 500)
bodyApplyForce(instance.node, -math.sin(heading) * push, 0, -math.cos(heading) * push)
bodySetAngularVelocity(instance.node, 0, -(instance.vars.dx or 0) * (instance.thrust.turn or 1.5), 0)
end
}
AUTHOR.behaviours.joint = {
help = "Joins this body to another's -- or to the world, with no other -- by a hinge, a ball, or a slider through a world point along an axis.",
params = { type = "string", other = "string", ax = "number", ay = "number", az = "number", dx = "number", dy = "number", dz = "number" },
choices = { type = { "hinge", "ball", "slider" } },
attach = function(instance, params)
instance.jointWanted = params
end,
step = function(instance)
-- Made on the first frame, once every body of the room exists.
local p = instance.jointWanted
if p == nil then
return
end
instance.jointWanted = nil
local other = p.other and AUTHOR_BY_ID[p.other] or nil
local node = other and other.node or -1
local x, y, z = authorPosition(instance)
if p.type == "ball" then
instance.joint = jointBall(instance.node, node, p.ax or x, p.ay or y, p.az or z)
elseif p.type == "slider" then
instance.joint = jointSlider(instance.node, node, p.ax or x, p.ay or y, p.az or z, p.dx or 1, p.dy or 0, p.dz or 0)
else
instance.joint = jointHinge(instance.node, node, p.ax or x, p.ay or y, p.az or z, p.dx or 0, p.dy or 1, p.dz or 0)
end
end
}
AUTHOR.behaviours.turret = {
help = "Fires at the nearest instance of a type within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself.",
params = { targets = "type", range = "number", rate = "number", spawns = "type", damage = "number", speed = "number" },
attach = function(instance, params)
instance.turret = params
instance.lastShot = -math.huge
end,
step = function(instance)
local p = instance.turret
if authorTime() - instance.lastShot < (p.rate or 1) then
return
end
local nearest, best = nil, p.range or 6
for _, target in ipairs(authorEach(p.targets or "")) do
local far = authorDistance3D(instance, target)
if far < best then
nearest, best = target, far
end
end
if nearest == nil then
return
end
instance.lastShot = authorTime()
if p.spawns then
local x, y, z = authorPosition(instance)
local tx, ty, tz = authorPosition(nearest)
local speed = p.speed or 12
local dx, dy, dz = tx - x, ty - y, tz - z
local far = math.max(0.001, math.sqrt(dx * dx + dy * dy + dz * dz))
authorSpawn(p.spawns, x, y, z, { vars = { vx = dx / far * speed, vy = dy / far * speed, vz = dz / far * speed }, owner = instance.id })
else
authorDamage(nearest, p.damage or 1)
end
authorBehaviourEvent(instance, "fire")
end
}
AUTHOR.behaviours.branching = {
help = "Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. Raises branchTaken and branchMissed.",
params = { track = "track" },
attach = function(instance, params)
instance.branching = params
instance.branchAt = nil
end,
step = function(instance)
local track = authorTrackNamed(instance.branching.track or "")
local frame = discGetFrame()
if track == nil then
return
end
for _, branch in ipairs(track.branches or {}) do
if (frame >= branch.from) and (frame < branch.to) then
if instance.branchAt ~= branch then
instance.branchAt = branch
instance.decided = false
authorEvent("branchOpen", { self = instance, move = branch.move })
end
if not instance.decided then
local key = keyValue(branch.move)
local switch = switchValue(branch.switch)
if (key and authorKeyPressed(key)) or (switch and pressedNow[switch]) then
instance.decided = true
if branch.success then
discSkipToFrame(branch.success)
end
authorEvent("branchTaken", { self = instance, move = branch.move })
end
end
elseif (instance.branchAt == branch) and (frame >= branch.to) and not instance.decided then
instance.decided = true
if branch.fail then
discSkipToFrame(branch.fail)
end
authorEvent("branchMissed", { self = instance, move = branch.move })
end
end
end
}
AUTHOR.behaviours.spin = {
help = "Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second.",
params = { rate = "number" },
attach = function(instance, params)
instance.spinRate = params.rate or 90
instance.vars.angle = instance.vars.angle or 0
end,
step = function(instance, dt)
if AUTHOR_3D then
local rx, ry, rz = nodeGetRotation(instance.node)
nodeSetRotation(instance.node, rx, (ry + instance.spinRate * dt) % 360, rz)
else
instance.vars.angle = (instance.vars.angle + instance.spinRate * dt) % 360
end
end
}
AUTHOR.behaviours.frames = {
help = "Steps a sprite look's frame: a range of frames per state, as \"idle=1-1, walk=2-5\", at a rate a second. A sheet drawn facing both ways names the other way's frames stateLeft or stateRight (walkLeft), used while the var facing is that way instead of mirroring the look.",
params = { fps = "number", states = "string" },
attach = function(instance, params)
instance.frameRanges = {}
for state, from, to in string.gmatch(tostring(params.states or ""), "(%w+)%s*=%s*(%d+)%s*-%s*(%d+)") do
instance.frameRanges[state] = { tonumber(from), tonumber(to) }
end
instance.fps = params.fps or 8
instance.frameClock = 0
instance.vars.frame = instance.vars.frame or 1
end,
step = function(instance, dt)
local state = instance.vars.state or "idle"
local range = instance.frameRanges[state]
local facing = instance.vars.facing
local faced = facing and instance.frameRanges[state .. facing:sub(1, 1):upper() .. facing:sub(2)]
-- Frames drawn the way the instance faces are used over the plain ones, and the look is
-- told, so it does not mirror them as well.
instance.drawnFacing = faced and facing or nil
range = faced or range or instance.frameRanges.idle
if range == nil then
return
end
instance.frameClock = instance.frameClock + dt * instance.fps
local count = range[2] - range[1] + 1
local frame = range[1] + (math.floor(instance.frameClock) % count)
instance.vars.frame = frame
end
}
AUTHOR.behaviours.water = {
help = "Fills a mesh look with water: its top is the surface, and bodies inside float or sink by their buoyancy.",
params = { density = "number", drag = "number" },
attach = function(instance, params)
local w, h, d = authorSize(instance)
bodyNew(instance.node, BODY_STATIC, SHAPE_BOX, w, h, d)
bodySetWater(instance.node, params.density or 1, params.drag or 0.5)
instance.hasBody = true
end
}
AUTHOR.behaviours.soft = {
help = "A mesh look made soft: a cloth, or a body inflated to a pressure.",
params = { type = "string", pressure = "number", stiffness = "number", mass = "number" },
choices = { type = { "cloth", "body" } },
attach = function(instance, params)
softNew(instance.node, (params.type == "cloth") and SOFT_CLOTH or SOFT_BODY)
if params.pressure then
softSetPressure(instance.node, params.pressure)
end
if params.stiffness then
softSetStiffness(instance.node, params.stiffness)
end
if params.mass then
softSetMass(instance.node, params.mass)
end
instance.soft = true
end
}
AUTHOR.behaviours.hotspot = {
help = "Something a verb can be used on: a name for the sentence line, a place to walk to first, and a polygon (x,y pairs) when its look's box will not do.",
params = { name = "string", polygon = "table", walkX = "number", walkY = "number", walkZ = "number" },
attach = function(instance, params)
instance.hotspot = params
instance.polygon = authorNumbers(params.polygon)
if AUTHOR_3D and not instance.hasBody then
local w, h, d = authorSize(instance)
local box = nodeNew()
nodeSetParent(box, instance.node)
nodeSetPosition(box, 0, h / 2, 0)
bodyNew(box, BODY_KINEMATIC, SHAPE_BOX, w, h, d)
bodySetTrigger(box, true)
AUTHOR_BY_NODE[box] = instance
instance.nodes[#instance.nodes + 1] = box
end
end
}
AUTHOR.behaviours.hitbox = {
help = "The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none.",
params = { track = "track" },
attach = function(instance, params)
instance.hitTrack = params.track
instance.isTarget = true
end
}
-- ===== Events =================================================================================
--
-- What a rule can be triggered by. filter names the parameters a rule may give to narrow it;
-- the runtime matches them against the event's context.
AUTHOR.events.frame = { help = "Every frame.", filter = {} }
AUTHOR.events.roomStart = { help = "A room has been entered.", filter = { room = "room" } }
AUTHOR.events.roomEnd = { help = "A room is about to be left.", filter = { room = "room" } }
AUTHOR.events.pressed = { help = "A key or a switch went down.", filter = { key = "scancode", switch = "switch", player = "number" } }
AUTHOR.events.released = { help = "A key or a switch came up.", filter = { key = "scancode", switch = "switch", player = "number" } }
AUTHOR.events.collision = { help = "An instance of type a began touching one of type b (self is a, other is b).", filter = { a = "type", b = "type" } }
AUTHOR.events.hit = { help = "A gun shot landed on self; other is the gun.", filter = { type = "type", player = "number" } }
AUTHOR.events.miss = { help = "A gun shot hit nothing; self is the gun. event.offscreen says whether it left the screen.", filter = { player = "number" } }
AUTHOR.events.death = { help = "self's health reached zero.", filter = { type = "type" } }
AUTHOR.events.spawn = { help = "self has just been made.", filter = { type = "type" } }
AUTHOR.events.timer = { help = "A timer on self went off; event.name says which.", filter = { name = "string", type = "type" } }
AUTHOR.events.frameReached = { help = "The disc passed a frame.", filter = { frame = "number" } }
AUTHOR.events.gameOver = { help = "The game ended.", filter = {} }
AUTHOR.events.enter = { help = "An instance of type b entered a trigger of type a (self is the trigger, other what entered).", filter = { a = "type", b = "type" } }
AUTHOR.events.leave = { help = "An instance of type b left a trigger of type a.", filter = { a = "type", b = "type" } }
AUTHOR.events.arrived = { help = "A seeking instance reached its target.", filter = { type = "type" } }
AUTHOR.events.stopped = { help = "A rail camera reached a stop; event.name says which. pathNext moves it on.", filter = { name = "string" } }
AUTHOR.events.railEnd = { help = "A rail camera reached the end of its track.", filter = {} }
AUTHOR.events.verb = { help = "A verb was used on a hotspot (self), with an item or not: \"use key on door\". The most specific rule wins.", filter = { verb = "string", target = "string", item = "string" }, exclusive = true }
AUTHOR.events.said = { help = "A line was typed at the parser: its verb, noun, and second noun as the words table knows them, or unknown for a word it does not. The most specific rule wins.", filter = { verb = "string", noun = "string", second = "string" }, exclusive = true }
AUTHOR.events.branchOpen = { help = "A branch's window opened; event.move says what it wants.", filter = {} }
AUTHOR.events.branchTaken = { help = "The move was made in time and the disc went to the success frame.", filter = {} }
AUTHOR.events.branchMissed = { help = "The window closed without the move and the disc went to the fail frame.", filter = {} }
AUTHOR.events.midi = { help = "A MIDI note came in on the port the game opened; event.pitch and event.velocity say which and how hard. (pitch, since note is what a rule's comment is called.)", filter = { pitch = "number" } }
AUTHOR.events.patrolEnd = { help = "A patrolling instance reached the last of its points.", filter = { type = "type" } }
AUTHOR.events.animationDone = { help = "A clip that does not loop ended on self; event.state says which state it was for.", filter = { type = "type", state = "state" } }
AUTHOR.events.soundDone = { help = "A clip that playSound or a sound behaviour started has ended; event.file says which, and self is the instance that played it.", filter = { file = "file", type = "type" } }
-- ===== Conditions =============================================================================
--
-- Every entry emits a Lua expression. Parameters arrive as Lua fragments.
AUTHOR.conditions.keyHeld = {
help = "A key is down.",
params = { key = "scancode" },
emit = function(p) return "authorKeyHeld(" .. p.key .. ")" end
}
AUTHOR.conditions.switchHeld = {
help = "A pad, gun, or mouse switch is down.",
params = { switch = "switch" },
emit = function(p) return "authorSwitchHeld(" .. p.switch .. ")" end
}
AUTHOR.conditions.keyPressed = {
help = "A key went down this frame.",
params = { key = "scancode" },
emit = function(p) return "authorKeyPressed(" .. p.key .. ")" end
}
AUTHOR.conditions.test = {
help = "An expression is true: self.health <= 0, count(\"zombie\") == 0, score > 1000.",
params = { expr = "expression" },
emit = function(p) return "(" .. p.expr .. ")" end
}
AUTHOR.conditions.timeBetween = {
help = "The game is between two moments, in seconds.",
params = { from = "number", to = "number" },
emit = function(p) return "(authorTime() >= " .. p.from .. " and authorTime() < " .. 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 "authorDiscBetween(" .. p.from .. ", " .. p.to .. ")" end
}
AUTHOR.conditions.onGround = {
help = "A platformer has ground under it.",
params = { entity = "entity" },
emit = function(p) return "authorOnGround(" .. p.entity .. ")" end
}
AUTHOR.conditions.touching = {
help = "Two instances overlap.",
params = { entity = "entity", other = "entity" },
emit = function(p) return "authorTouching(" .. p.entity .. ", " .. p.other .. ")" end
}
AUTHOR.conditions.below = {
help = "An instance has fallen past a line -- a pit, or the bottom of the screen.",
params = { entity = "entity", y = "number" },
emit = function(p) return "(authorY(" .. p.entity .. ") > " .. p.y .. ")" end
}
AUTHOR.conditions.inState = {
help = "An instance's state is this.",
params = { entity = "entity", state = "state" },
emit = function(p) return "authorInState(" .. p.entity .. ", " .. p.state .. ")" end
}
AUTHOR.conditions.pointerIn = {
help = "A player's pointer is over an instance.",
params = { entity = "entity", player = "number" },
emit = function(p) return "authorPointerIn(" .. p.entity .. ", " .. p.player .. ")" end
}
AUTHOR.conditions.pointerOffscreen = {
help = "A player's pointer is off the picture, which is how a light gun reloads.",
params = { player = "number" },
emit = function(p) return "authorPointerOffscreen(" .. p.player .. ")" end
}
AUTHOR.conditions.onScreen = {
help = "An instance's box is at least partly on the overlay.",
params = { entity = "entity" },
emit = function(p) return "authorOnScreen(" .. p.entity .. ")" end
}
AUTHOR.conditions.every = {
help = "True once every so many seconds, for a rule that fires steadily.",
params = { seconds = "number", tag = "string" },
emit = function(p) return "authorEvery(" .. p.seconds .. ", " .. p.tag .. ")" end
}
AUTHOR.conditions.chance = {
help = "True with a probability, 0 to 1.",
params = { p = "number" },
emit = function(p) return "(math.random() < " .. p.p .. ")" end
}
AUTHOR.conditions.hitPart = {
help = "The shot landed on a named zone of the target: head, weakspot.",
params = { part = "string" },
emit = function(p) return "(event[\"part\"] == " .. p.part .. ")" end
}
AUTHOR.conditions.waiting = {
help = "A rail camera is held at a stop.",
params = { entity = "entity" },
emit = function(p) return "authorWaiting(" .. p.entity .. ")" end
}
AUTHOR.conditions.has = {
help = "The inventory holds an item.",
params = { item = "string" },
emit = function(p) return "authorHas(" .. p.item .. ")" end
}
AUTHOR.conditions.hover = {
help = "A player's pointer is over the named hotspot.",
params = { target = "string", player = "number" },
defaults = { player = 1 },
emit = function(p) return "(authorHover(" .. p.player .. ") == " .. p.target .. ")" end
}
AUTHOR.conditions.once = {
help = "Only the first time this rule would run -- per instance and per room unless scope is \"game\".",
params = { tag = "string", scope = "string" },
optional = { scope = true },
emit = function(p) return "authorOnce(" .. p.tag .. ", self, " .. p.scope .. ")" end
}
-- ===== Actions ================================================================================
--
-- Every entry emits a Lua statement. waits marks the ones that take time: a rule with any of
-- them runs as a coroutine, and the action yields until it is done.
AUTHOR.actions.run = {
help = "Drive a platformer left (-1) or right (1) this frame.",
params = { entity = "entity", direction = "number" },
emit = function(p) return "authorRun(" .. p.entity .. ", " .. p.direction .. ")" end
}
AUTHOR.actions.jump = {
help = "Ask a platformer to jump.",
params = { entity = "entity" },
emit = function(p) return "authorJump(" .. p.entity .. ")" end
}
AUTHOR.actions.moveTo = {
help = "Put an instance somewhere.",
params = { entity = "entity", x = "number", y = "number", z = "number" },
defaults = { z = 0 },
emit = function(p) return "authorMoveTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end
}
AUTHOR.actions.setText = {
help = "Change what a text instance says.",
params = { entity = "entity", text = "expression" },
emit = function(p) return "authorSetVar(" .. p.entity .. ", \"text\", tostring(" .. p.text .. "))" end
}
AUTHOR.actions.show = {
help = "Show or hide an instance.",
params = { entity = "entity", visible = "boolean" },
emit = function(p) return "authorShow(" .. p.entity .. ", " .. p.visible .. ")" end
}
AUTHOR.actions.addScore = {
help = "Add to the score.",
params = { amount = "number" },
emit = function(p) return "authorAddVar(nil, \"score\", " .. p.amount .. ")" end
}
AUTHOR.actions.setVar = {
help = "Set a var on an instance, or on the game with no entity.",
params = { entity = "entity", name = "string", value = "expression" },
optional = { entity = true },
emit = function(p) return "authorSetVar(" .. p.entity .. ", " .. p.name .. ", " .. p.value .. ")" end
}
AUTHOR.actions.addVar = {
help = "Add to a var on an instance, or on the game with no entity.",
params = { entity = "entity", name = "string", amount = "number" },
optional = { entity = true },
emit = function(p) return "authorAddVar(" .. p.entity .. ", " .. p.name .. ", " .. p.amount .. ")" end
}
AUTHOR.actions.setState = {
help = "Change an instance's state.",
params = { entity = "entity", state = "state" },
emit = function(p) return "authorSetVar(" .. p.entity .. ", \"state\", " .. p.state .. ")" end
}
AUTHOR.actions.damage = {
help = "Take from an instance's health; at zero it dies.",
params = { entity = "entity", amount = "number" },
emit = function(p) return "authorDamage(" .. p.entity .. ", " .. p.amount .. ")" end
}
AUTHOR.actions.spawn = {
help = "Make an instance of a type at a point.",
params = { type = "type", x = "number", y = "number", z = "number" },
defaults = { z = 0 },
emit = function(p) return "authorSpawn(" .. p.type .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end
}
AUTHOR.actions.destroy = {
help = "Remove an instance.",
params = { entity = "entity" },
emit = function(p) return "authorDestroy(" .. p.entity .. ")" end
}
AUTHOR.actions.playSound = {
help = "Play a clip, at a volume of its own (0 to 100) under the game's.",
params = { file = "file", volume = "number" },
defaults = { volume = 100 },
emit = function(p) return "authorPlaySound(" .. p.file .. ", " .. p.volume .. ", self)" end
}
AUTHOR.actions.playMusic = {
help = "Play a track, looping.",
params = { file = "file" },
emit = function(p) return "authorPlayMusic(" .. p.file .. ")" end
}
AUTHOR.actions.stopMusic = {
help = "Stop the track that is playing.",
params = {},
emit = function() return "authorStopMusic()" end
}
AUTHOR.actions.emit = {
help = "A burst of particles at an instance: an explosion, sparks, a puff.",
params = { entity = "entity", count = "number", r = "number", g = "number", b = "number", speed = "number" },
defaults = { count = 20, r = 255, g = 200, b = 60, speed = 150 },
emit = function(p) return "authorEmit(" .. p.entity .. ", " .. p.count .. ", " .. p.r .. ", " .. p.g .. ", " .. p.b .. ", " .. p.speed .. ")" end
}
AUTHOR.actions.say = {
help = "Show a line for a while, and wait for it. With a hud it goes in the element called say; otherwise it is drawn.",
params = { text = "expression", seconds = "number" },
optional = { seconds = true },
waits = true,
emit = function(p) return "authorSay(" .. p.text .. ", " .. p.seconds .. ")" end
}
AUTHOR.actions.wait = {
help = "Wait some seconds before the next action.",
params = { seconds = "number" },
waits = true,
emit = function(p) return "authorWaitSeconds(" .. p.seconds .. ")" end
}
AUTHOR.actions.flash = {
help = "Flash the screen a colour.",
params = { r = "number", g = "number", b = "number", seconds = "number" },
defaults = { r = 255, g = 255, b = 255, seconds = 0.2 },
emit = function(p) return "authorFlash(" .. p.r .. ", " .. p.g .. ", " .. p.b .. ", " .. p.seconds .. ")" end
}
AUTHOR.actions.timerStart = {
help = "Start a named timer on an instance.",
params = { entity = "entity", name = "string", after = "number" },
emit = function(p) return "authorTimerStart(" .. p.entity .. ", " .. p.name .. ", " .. p.after .. ")" end
}
AUTHOR.actions.goTo = {
help = "Go to a room, taking the named entity along and placing it at a point there -- or where the entity called at stands in that room.",
params = { room = "room", entity = "entity", x = "number", y = "number", z = "number", at = "string" },
optional = { entity = true, x = true, y = true, z = true, at = true },
emit = function(p) return "authorGoTo(" .. p.room .. ", " .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ", " .. p.at .. ")" end
}
AUTHOR.actions.reload = {
help = "Fill a gun's ammo.",
params = { entity = "entity" },
emit = function(p) return "authorReload(" .. p.entity .. ")" end
}
AUTHOR.actions.discTo = {
help = "Send the disc to a frame -- the branch a QTE takes.",
params = { frame = "number" },
emit = function(p) return "discSkipToFrame(" .. p.frame .. ")" end
}
AUTHOR.actions.gameOver = {
help = "End the game: rules stop, the gameOver event fires, restart begins again.",
params = {},
emit = function() return "authorGameOver()" end
}
AUTHOR.actions.restart = {
help = "Begin the game again from its first room.",
params = {},
emit = function() return "authorRestart()" end
}
AUTHOR.actions.pathNext = {
help = "Send a rail camera on from its stop.",
params = { entity = "entity" },
emit = function(p) return "authorPathNext(" .. p.entity .. ")" end
}
AUTHOR.actions.playAnimation = {
help = "Play a model's clip, once or looping.",
params = { entity = "entity", clip = "string", loop = "boolean" },
defaults = { loop = false },
emit = function(p) return "authorPlayAnimation(" .. p.entity .. ", " .. p.clip .. ", " .. p.loop .. ")" end
}
AUTHOR.actions.lookAt = {
help = "Turn an instance toward another.",
params = { entity = "entity", target = "entity" },
emit = function(p) return "authorLookAt(" .. p.entity .. ", " .. p.target .. ")" end
}
AUTHOR.actions.push = {
help = "Shove a body, or a ragdoll's bone, along a direction.",
params = { entity = "entity", x = "number", y = "number", z = "number", bone = "string" },
defaults = { x = 0, y = 0, z = 0 },
optional = { bone = true },
emit = function(p) return "authorPush(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ", " .. p.bone .. ")" end
}
AUTHOR.actions.shake = {
help = "Shake the camera for a moment.",
params = { amount = "number", seconds = "number" },
defaults = { amount = 0.3, seconds = 0.4 },
emit = function(p) return "authorShake(" .. p.amount .. ", " .. p.seconds .. ")" end
}
AUTHOR.actions.cameraCut = {
help = "Draw the scene from another camera instance.",
params = { entity = "entity" },
emit = function(p) return "authorCameraCut(" .. p.entity .. ")" end
}
AUTHOR.actions.spawnAtPointer = {
help = "Make an instance of a type where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D.",
params = { type = "type", player = "number" },
defaults = { player = 1 },
emit = function(p) return "authorSpawnAtPointer(" .. p.type .. ", " .. p.player .. ")" end
}
AUTHOR.actions.walkTo = {
help = "Send a walker to a point, and wait until it gets there.",
params = { entity = "entity", x = "number", y = "number", z = "number" },
defaults = { z = 0 },
waits = true,
emit = function(p) return "authorWalkTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end
}
AUTHOR.actions.walkToPointer = {
help = "Send a walker where the player's pointer is, and wait.",
params = { entity = "entity", player = "number" },
defaults = { player = 1 },
waits = true,
emit = function(p) return "authorWalkToPointer(" .. p.entity .. ", " .. p.player .. ")" end
}
AUTHOR.actions.walkToHotspot = {
help = "Send a walker to a hotspot's walk point (the one the verb was used on, with self), and wait.",
params = { entity = "entity", target = "entity" },
waits = true,
emit = function(p) return "authorWalkToHotspot(" .. p.entity .. ", " .. p.target .. ")" end
}
AUTHOR.actions.face = {
help = "Turn a walker to face left or right, or toward an instance.",
params = { entity = "entity", direction = "string", target = "entity" },
optional = { direction = true, target = true },
emit = function(p) return "authorFace(" .. p.entity .. ", " .. p.direction .. ", " .. p.target .. ")" end
}
AUTHOR.actions.give = {
help = "Put an item in the inventory.",
params = { item = "string" },
emit = function(p) return "authorGive(" .. p.item .. ")" end
}
AUTHOR.actions.take = {
help = "Take an item out of the inventory.",
params = { item = "string" },
emit = function(p) return "authorTake(" .. p.item .. ")" end
}
AUTHOR.actions.setVerb = {
help = "Choose the verb the next click uses.",
params = { verb = "string" },
emit = function(p) return "authorSetVerb(" .. p.verb .. ")" end
}
AUTHOR.actions.nextVerb = {
help = "Cycle to the next verb in the game's list.",
params = {},
emit = function() return "authorNextVerb()" end
}
AUTHOR.actions.useItem = {
help = "Choose an inventory item for the next click: \"use key on ...\".",
params = { item = "string" },
emit = function(p) return "authorUseItem(" .. p.item .. ")" end
}
AUTHOR.actions.talk = {
help = "Run a dialogue from the game's dialogues, and wait for it to end.",
params = { dialogue = "string" },
waits = true,
emit = function(p) return "authorTalk(" .. p.dialogue .. ")" end
}
AUTHOR.actions.fade = {
help = "Fade the picture to black, or back (with out false), and wait.",
params = { seconds = "number", out = "boolean" },
defaults = { seconds = FADE_DEFAULT, out = true },
waits = true,
emit = function(p) return "authorFade(" .. p.seconds .. ", " .. p.out .. ")" end
}
AUTHOR.actions.saveGame = {
help = "Keep the whole game -- room, positions, vars, inventory -- in a numbered slot.",
params = { slot = "number" },
defaults = { slot = 1 },
emit = function(p) return "authorSaveGame(" .. p.slot .. ")" end
}
AUTHOR.actions.loadGame = {
help = "Bring a slot back.",
params = { slot = "number" },
defaults = { slot = 1 },
emit = function(p) return "authorLoadGame(" .. p.slot .. ")" end
}
AUTHOR.actions.die = {
help = "The Sierra death: a message, then the game starts over.",
params = { text = "expression" },
waits = true,
emit = function(p) return "authorDie(" .. p.text .. ")" end
}
AUTHOR.actions.submitScore = {
help = "Send the score to the master service's board for this game, queued until it can go.",
params = { board = "string" },
emit = function(p) return "authorSubmitScore(" .. (p.board or '"default"') .. ")" end
}
AUTHOR.actions.credit = {
help = "A coin: one more credit.",
params = {},
emit = function() return "authorAddVar(nil, \"credits\", 1)" 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
}
-- ===== Instances ==============================================================================
local function nextId(typeName)
local id = typeName .. "#" .. nextSerial
nextSerial = nextSerial + 1
return id
end
local function copyOf(value)
local out
if type(value) ~= "table" then
return value
end
out = {}
for key, item in pairs(value) do
out[key] = copyOf(item)
end
return out
end
-- Makes an instance of a type. overrides is an entities entry: id, x, y, z, and vars.
local function make(typeName, x, y, z, overrides)
local def = AUTHOR_GAME.types[typeName]
local instance
if def == nil then
debugPrint("Author: no type called '" .. tostring(typeName) .. "'")
return nil
end
overrides = overrides or {}
instance = {
id = overrides.id or nextId(typeName),
type = typeName,
def = def,
look = copyOf(def.look or { kind = "none" }),
behaviours = def.behaviours or {},
vars = copyOf(def.vars or {}),
visible = true,
alive = true,
node = nodeNew(),
nodes = {}
}
for key, value in pairs(overrides.vars or {}) do
instance.vars[key] = copyOf(value)
end
for key, value in pairs(overrides) do
if (key ~= "id") and (key ~= "x") and (key ~= "y") and (key ~= "z") and (key ~= "rx") and (key ~= "ry") and (key ~= "rz") and (key ~= "scale") and (key ~= "type") and (key ~= "vars") then
instance.vars[key] = copyOf(value)
end
end
instance.vars.drive = 0
instance.vars.state = instance.vars.state or "idle"
nodeSetPosition(instance.node, x or 0, y or 0, z or 0)
if overrides.rx or overrides.ry or overrides.rz then
nodeSetRotation(instance.node, overrides.rx or 0, overrides.ry or 0, overrides.rz or 0)
end
-- A scale on the entity scales its node, and with it its look, its body (bodies take the node's
-- scale), and how big it counts as.
instance.scale = overrides.scale or 1
if instance.scale ~= 1 then
nodeSetScale(instance.node, instance.scale)
end
if overrides.rz and not AUTHOR_3D then
instance.vars.angle = overrides.rz
end
local look = AUTHOR.looks[instance.look.kind]
if look == nil then
debugPrint("Author: no look called '" .. tostring(instance.look.kind) .. "' on " .. instance.id .. "; drawing a box")
instance.look.kind = "box"
look = AUTHOR.looks.box
end
if look.load then
look.load(instance)
end
AUTHOR_LIVE[#AUTHOR_LIVE + 1] = instance
AUTHOR_BY_ID[instance.id] = instance
AUTHOR_BY_NODE[instance.node] = instance
for _, b in ipairs(instance.behaviours) do
local behaviour = AUTHOR.behaviours[b.kind]
if behaviour == nil then
debugPrint("Author: no behaviour called '" .. tostring(b.kind) .. "'")
elseif behaviour.attach then
behaviour.attach(instance, b)
end
end
return instance
end
function authorSpawn(typeName, x, y, z, overrides)
local instance = make(typeName, x, y, z, overrides)
if instance ~= nil then
authorEvent("spawn", { self = instance })
end
return instance
end
-- Removes an instance now. Its node goes with it, and any body or player on the node.
function authorDestroy(instance)
if (instance == nil) or not instance.alive then
return
end
instance.alive = false
AUTHOR_BY_ID[instance.id] = nil
AUTHOR_BY_NODE[instance.node] = nil
for _, node in ipairs(instance.nodes) do
AUTHOR_BY_NODE[node] = nil
end
if instance.ragdoll then
ragdollDelete(instance.model)
end
for at = #AUTHOR_LIVE, 1, -1 do
if AUTHOR_LIVE[at] == instance then
table.remove(AUTHOR_LIVE, at)
end
end
for at = #timers, 1, -1 do
if timers[at].instance == instance then
table.remove(timers, at)
end
end
if instance.emitter then
emitterDelete(instance.emitter)
end
if instance.ownSprite then
spriteUnload(instance.ownSprite)
end
if instance.agent then
navAgentDelete(instance.agent)
AUTHOR_AGENTS[instance.agent] = nil
end
if instance.navNode and (instance.navNode ~= instance.node) then
nodeDelete(instance.navNode)
end
nodeDelete(instance.node)
end
-- ===== The runtime the generated Lua calls ====================================================
function authorKeyHeld(scancode)
return AUTHOR_KEYS[scancode] == true
end
function authorKeyPressed(scancode)
return pressedNow[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(instance)
return (instance ~= nil) and instance.player and playerIsOnGround(instance.node)
end
function authorTouching(a, b)
if (a == nil) or (b == nil) then
return false
end
if AUTHOR_3D then
local ax, ay, az = authorPosition(a)
local bx, by, bz = authorPosition(b)
local aw, ah, ad = authorSize(a)
local bw, bh, bd = authorSize(b)
return (math.abs(ax - bx) * 2 < aw + bw) and (math.abs(ay - by) * 2 < ah + bh) and (math.abs(az - bz) * 2 < (ad or aw) + (bd or bw))
end
local ax, ay, aw, ah = authorBounds(a)
local bx, by, bw, bh = authorBounds(b)
return collideRects(ax, ay, aw, ah, bx, by, bw, bh)
end
function authorInState(instance, state)
return (instance ~= nil) and (instance.vars.state == state)
end
function authorOnScreen(instance)
if instance == nil then
return false
end
local x, y, w, h = authorBounds(instance)
return collideRects(x, y, w, h, 0, 0, overlayGetWidth(), overlayGetHeight())
end
-- True the first time only, per instance and room by default; "game" makes it once ever.
function authorOnce(tag, instance, scope)
local key = tag
if scope ~= "game" then
key = (AUTHOR_ROOM and AUTHOR_ROOM.name or "") .. "/" .. (instance and instance.id or "") .. "/" .. tag
end
if onceSeen[key] then
return false
end
onceSeen[key] = true
return true
end
local everyLast = {}
function authorEvery(seconds, tag)
local now = authorTime()
if (everyLast[tag] == nil) or (now - everyLast[tag] >= seconds) then
everyLast[tag] = now
return true
end
return false
end
function authorRun(instance, direction)
if instance then
instance.vars.drive = direction
end
end
function authorJump(instance)
if instance and instance.player then
playerJump(instance.node, instance.jump)
end
end
function authorMoveTo(instance, x, y, z)
if instance == nil then
return
end
if instance.player then
playerSetPosition(instance.node, x, y, z or 0)
else
nodeSetPosition(instance.node, x, y, z or 0)
end
end
function authorShow(instance, visible)
if instance then
instance.visible = visible
end
end
-- A var on an instance, or on the game with nil.
function authorSetVar(instance, name, value)
if instance == nil then
AUTHOR_VARS[name] = value
elseif instance.alive then
instance.vars[name] = value
end
end
function authorAddVar(instance, name, amount)
local vars = instance and instance.vars or AUTHOR_VARS
vars[name] = (vars[name] or 0) + amount
end
function authorDamage(instance, amount)
if (instance == nil) or not instance.alive then
return
end
instance.vars.health = (instance.vars.health or 1) - amount
authorBehaviourEvent(instance, "hit")
if instance.vars.health <= 0 then
instance.vars.state = "dead"
authorBehaviourEvent(instance, "death")
authorEvent("death", { self = instance })
if not instance.keepDead then
authorDestroy(instance)
end
end
end
-- Whether a sprite look is drawn mirrored: its art faces one way (faces: right unless said), the
-- instance faces the other, and its sheet has no frames drawn that way (the frames behaviour says
-- so by drawnFacing). Facing up or down never mirrors.
function authorMirrored(instance)
local faces = instance.look.faces or "right"
local opposite = (faces == "right") and "left" or ((faces == "left") and "right" or nil)
if (opposite == nil) or (instance.vars.facing ~= opposite) then
return false
end
return instance.drawnFacing ~= opposite
end
-- A clip at a Forge volume, 0 to 100, under the engine's master.
-- Plays a clip at a volume of its own, remembering who played it so soundDone can say so.
function authorPlaySound(file, volume, owner)
local channel = soundPlay(soundFor(file), 0, math.floor(math.max(0, math.min(100, volume or 100)) * SOUND_MAX_VOLUME / 100 + 0.5))
if channel and (channel >= 0) then
playing[channel] = { file = file, self = owner }
end
return channel
end
-- The engine says a channel has finished: soundDone for the clip that was on it, with the
-- instance that played it as self.
function authorSoundDone(channel)
local was = playing[channel]
if was == nil then
return
end
playing[channel] = nil
authorEvent("soundDone", { self = was.self, file = was.file })
end
function authorPlayMusic(file)
if music then
musicStop(music)
end
music = musicLoad(authorFile(file))
musicPlay(music, -1)
end
function authorStopMusic()
if music then
musicStop(music)
end
end
function authorEmit(instance, count, r, g, b, speed)
if instance == nil then
return
end
local x, y = authorPosition(instance)
if instance.emitter == nil then
instance.emitter = emitterNew()
emitterSetLife(instance.emitter, 0.3, 0.7)
emitterSetSpread(instance.emitter, 360)
emitterSetSize(instance.emitter, 6, 1)
end
emitterSetPosition(instance.emitter, x, y)
emitterSetColor(instance.emitter, r, g, b, 255, r, g, b, 0)
emitterSetSpeed(instance.emitter, speed * 0.5, speed)
emitterBurst(instance.emitter, count)
end
function authorFlash(r, g, b, seconds)
flashLine = { r = r, g = g, b = b, seconds = seconds, till = authorTime() + seconds }
end
function authorTimerStart(instance, name, after, every)
if instance == nil then
return
end
timers[#timers + 1] = { instance = instance, name = name, at = authorTime() + (after or 0), every = every }
end
function authorReload(instance)
if instance and instance.gun then
instance.vars.ammo = instance.gun.ammo or 6
authorBehaviourEvent(instance, "reload")
end
end
function authorGameOver()
if gameOver then
return
end
gameOver = true
-- The results card, and the best score kept for next time.
local best = math.max(AUTHOR_VARS.best or 0, AUTHOR_VARS.score or 0)
AUTHOR_VARS.best = best
saveSet(BEST_KEY, best)
results = { score = AUTHOR_VARS.score or 0, best = best }
authorEvent("gameOver", {})
end
-- The master service's client is loaded when a score is first sent, and pumped from then on. A
-- test may supply masterSubmitScore itself.
function authorSubmitScore(board)
if not masterReady then
if masterSubmitScore == nil then
dofile("Singe/Net.singe")
dofile("Singe/Master.singe")
masterLoad()
end
masterReady = true
end
masterSubmitScore(singeGetGameId(), AUTHOR_VARS.score or 0, board)
end
local function updateBezel()
if bezel == nil then
return
end
local shows = { { "score", 1 }, { "lives", 1 }, { "score2", 2 }, { "lives2", 2 } }
for _, item in ipairs(shows) do
local value = AUTHOR_VARS[item[1]]
if (value ~= nil) and (bezel.shown[item[1]] ~= value) then
bezel.shown[item[1]] = value
if item[1]:sub(1, 5) == "score" then
scoreBezelScore(item[2], value)
else
scoreBezelLives(item[2], value)
end
end
end
if (AUTHOR_VARS.credits ~= nil) and (bezel.shown.credits ~= AUTHOR_VARS.credits) then
bezel.shown.credits = AUTHOR_VARS.credits
scoreBezelCredits(AUTHOR_VARS.credits)
end
end
-- ===== The scene ==============================================================================
-- Where a target named in a behaviour is: "camera", an entity's id, or nothing.
function authorTargetInstance(name)
if name == "camera" then
return cameraNode and AUTHOR_BY_NODE[cameraNode] or nil
end
return AUTHOR_BY_ID[name or ""]
end
function authorTargetPosition(name)
local target = authorTargetInstance(name)
if target == nil then
return nil
end
return authorPosition(target)
end
-- The camera's forward and right on the ground, for a character that moves relative to the view.
-- Without a camera, world axes: forward is -Z.
function authorCameraAxes()
local camera = cameraNode and AUTHOR_BY_NODE[cameraNode] or nil
if (camera == nil) or (camera.camera == nil) then
return 0, -1, 1, 0
end
local yaw
if camera.camera.mode == "first" or camera.camera.mode == "orbit" then
yaw = math.rad(camera.yaw)
else
local x, _, z = authorPosition(camera)
local tx, _, tz = authorTargetPosition(camera.camera.target)
if tx == nil then
return 0, -1, 1, 0
end
yaw = math.atan(x - tx, z - tz)
end
-- Forward is away from the camera, right is ninety degrees round from it.
local fx, fz = -math.sin(yaw), -math.cos(yaw)
return fx, fz, -fz, fx
end
-- The instance a node belongs to. Every node an instance owns is registered when it is made, so
-- this never asks the engine -- which matters because physics can report a node a rule has already
-- deleted, a prize destroyed as it is entered, and asking about that node would end the game.
function authorInstanceOf(node)
return node and AUTHOR_BY_NODE[node] or nil
end
function authorWaiting(instance)
return (instance ~= nil) and (instance.rail ~= nil) and (instance.rail.waiting ~= nil)
end
function authorPathNext(instance)
if instance and instance.rail then
instance.rail.waiting = nil
end
end
function authorPlayAnimation(instance, clip, loop)
if instance and instance.model then
animationPlay(instance.model, clip, loop)
instance.playing = clip
end
end
function authorLookAt(instance, target)
if instance and target then
local x, y, z = authorPosition(target)
nodeLookAt(instance.model or instance.node, x, y, z)
end
end
function authorPush(instance, x, y, z, bone)
if instance == nil then
return
end
if instance.ragdoll and bone then
ragdollApplyImpulse(instance.model, bone, x, y, z)
elseif instance.hasBody then
bodyApplyImpulse(instance.node, x, y, z)
end
end
local shaking = nil
function authorShake(amount, seconds)
shaking = { amount = amount, till = authorTime() + seconds }
end
function authorCameraCut(instance)
if instance then
cameraNode = instance.node
cameraSet(instance.node)
end
end
-- One step along a rail: the camera track's points are positions by time, each looking at a
-- point or an entity, and a point may be a stop that holds the rail until pathNext.
function authorRailStep(instance, dt)
local rail = instance.rail
local track = authorTrackNamed(instance.camera.track or "")
local points = track and track.points or {}
if #points == 0 then
return
end
if rail.waiting == nil then
rail.t = rail.t + dt
end
local before, after
for _, point in ipairs(points) do
if (point.at <= rail.t) and ((before == nil) or (point.at > before.at)) then
before = point
end
if (point.at >= rail.t) and ((after == nil) or (point.at < after.at)) then
after = point
end
end
if before == nil then
before = after
end
if after == nil then
after = before
if not rail.ended then
rail.ended = true
authorEvent("railEnd", { self = instance })
end
end
-- A stop holds the rail the moment it is reached, once.
if before.stop and not rail.passed[before] and (rail.t >= before.at) then
rail.passed[before] = true
rail.waiting = before.stop
rail.t = before.at
authorEvent("stopped", { self = instance, name = before.stop })
authorWavesAtStop(before.stop)
end
local k = 0
if (after ~= before) and (after.at > before.at) then
k = (rail.t - before.at) / (after.at - before.at)
end
local x = before.x + (after.x - before.x) * k
local y = before.y + (after.y - before.y) * k
local z = before.z + (after.z - before.z) * k
if shaking then
if authorTime() >= shaking.till then
shaking = nil
else
x = x + (math.random() - 0.5) * shaking.amount
y = y + (math.random() - 0.5) * shaking.amount
end
end
nodeSetPosition(instance.node, x, y, z)
local look = before.look
if type(look) == "string" then
local lx, ly, lz = authorTargetPosition(look)
if lx then
nodeLookAt(instance.node, lx, ly, lz)
end
elseif type(look) == "table" then
local lx = look.x or look[1] or 0
local ly = look.y or look[2] or 0
local lz = look.z or look[3] or 0
if (after ~= before) and (type(after.look) == "table") then
lx = lx + ((after.look.x or after.look[1] or 0) - lx) * k
ly = ly + ((after.look.y or after.look[2] or 0) - ly) * k
lz = lz + ((after.look.z or after.look[3] or 0) - lz) * k
end
nodeLookAt(instance.node, lx, ly, lz)
end
end
-- Physics reports a contact between two bodies; the rules hear about it by type.
function authorCollision(nodeA, nodeB, x, y, z, speed)
local a = authorInstanceOf(nodeA)
local b = authorInstanceOf(nodeB)
if a and b then
authorEvent("collision", { self = a, other = b, x = x, y = y, z = z, speed = speed })
authorEvent("collision", { self = b, other = a, x = x, y = y, z = z, speed = speed })
end
end
function authorTrigger(trigger, other, entered)
local a = authorInstanceOf(trigger)
local b = authorInstanceOf(other)
if a and b then
authorEvent(entered and "enter" or "leave", { self = a, other = b })
end
end
-- A MIDI message from the input port: note-on with a velocity is a note, and the rules hear it.
function authorMidi(status, data1, data2)
local kind = math.floor((status or 0) / 16)
if (kind == 9) and ((data2 or 0) > 0) then
authorEvent("midi", { pitch = data1, velocity = data2, channel = (status % 16) + 1 })
end
end
function authorMouseMoved(x, y, xr, yr)
AUTHOR_MOUSE.dx = AUTHOR_MOUSE.dx + (xr or 0)
AUTHOR_MOUSE.dy = AUTHOR_MOUSE.dy + (yr or 0)
end
-- ===== Walking, hotspots, verbs, and the parser ===============================================
--
-- The adventure's pieces (FORGE.md section 3). A room's walk areas become a navigation mesh so
-- walking in a painted room and in a modelled one is the same agent; a hotspot is where a verb
-- lands; the sentence line is built from the verb, the hotspot under the pointer, and the item in
-- hand; the parser turns a typed line into the same kind of event.
AUTHOR_AGENTS = {} -- Instances by navigation agent, for onNavArrived.
-- Numbers from a table, or from text like "10,20, 30,40" typed in the editor.
function authorNumbers(value)
if type(value) == "table" then
return value
end
if type(value) ~= "string" then
return nil
end
local out = {}
for number in string.gmatch(value, "-?[%d.]+") do
out[#out + 1] = tonumber(number)
end
return (#out > 0) and out or nil
end
-- Ear clipping: a polygon of x,y pairs into triangles, as vertex indexes counted from 1.
local function triangulate(polygon)
local points = {}
local order = {}
local tris = {}
for at = 1, #polygon - 1, 2 do
points[#points + 1] = { polygon[at], polygon[at + 1] }
order[#order + 1] = #points
end
local function area2(a, b, c)
return (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2])
end
local function inside(p, a, b, c)
local s1 = area2(a, b, p)
local s2 = area2(b, c, p)
local s3 = area2(c, a, p)
return ((s1 >= 0) and (s2 >= 0) and (s3 >= 0)) or ((s1 <= 0) and (s2 <= 0) and (s3 <= 0))
end
local total = 0
for at = 1, #points do
local a = points[at]
local b = points[(at % #points) + 1]
total = total + a[1] * b[2] - b[1] * a[2]
end
local sign = (total >= 0) and 1 or -1
local guard = 0
while (#order > 3) and (guard < 1000) do
local clipped = false
guard = guard + 1
for at = 1, #order do
local i0 = order[((at - 2) % #order) + 1]
local i1 = order[at]
local i2 = order[(at % #order) + 1]
local a, b, c = points[i0], points[i1], points[i2]
if area2(a, b, c) * sign > 0 then
local ear = true
for _, other in ipairs(order) do
if (other ~= i0) and (other ~= i1) and (other ~= i2) and inside(points[other], a, b, c) then
ear = false
end
end
if ear then
tris[#tris + 1] = { i0, i1, i2 }
table.remove(order, at)
clipped = true
break
end
end
end
if not clipped then
break
end
end
if #order == 3 then
tris[#tris + 1] = { order[1], order[2], order[3] }
end
return points, tris
end
-- The current room's navigation mesh, baked once: from its walk polygons in 2D, from the meshes
-- of the entities navFrom names in 3D. nil for a room with neither.
function authorRoomNav()
local room = AUTHOR_ROOM
if room == nil then
return nil
end
if room.nav ~= nil then
return room.nav or nil
end
if room.walk and (#room.walk > 0) then
local nav = navNew(NAV_RADIUS_2D, NAV_HEIGHT_2D, 60, 0.05)
for _, polygon in ipairs(room.walk) do
local points, tris = triangulate(authorNumbers(polygon) or {})
local positions = {}
local indices = {}
for _, point in ipairs(points) do
positions[#positions + 1] = point[1] * NAV_SCALE
positions[#positions + 1] = 0
positions[#positions + 1] = point[2] * NAV_SCALE
end
for _, tri in ipairs(tris) do
-- Wound so the face looks up, whichever way the author drew the outline.
local a, b, c = points[tri[1]], points[tri[2]], points[tri[3]]
local up = (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2])
if up > 0 then
indices[#indices + 1] = tri[1]
indices[#indices + 1] = tri[3]
indices[#indices + 1] = tri[2]
else
indices[#indices + 1] = tri[1]
indices[#indices + 1] = tri[2]
indices[#indices + 1] = tri[3]
end
end
if #indices > 0 then
local node = nodeNew()
nodeSetMesh(node, meshNew(positions, nil, nil, indices))
nodeSetVisible(node, false)
navAddNode(nav, node)
end
end
navBuild(nav)
room.nav = { nav = nav, scale = NAV_SCALE, radius = NAV_RADIUS_2D, height = NAV_HEIGHT_2D }
elseif room.navFrom then
local nav = navNew(0.3, 1.0, 45, 0.4)
for _, id in ipairs(room.navFrom) do
local floor = AUTHOR_BY_ID[id]
if floor then
navAddNode(nav, floor.node)
end
end
navBuild(nav)
room.nav = { nav = nav, scale = 1, radius = 0.3, height = 1.0 }
else
room.nav = false
end
return room.nav or nil
end
-- Sends a walker somewhere. Answers whether it set off.
function authorWalkStart(instance, x, y, z)
local nav = authorRoomNav()
if (instance == nil) or (instance.agent == nil) or (nav == nil) then
return false
end
local tx, ty, tz
if AUTHOR_3D then
tx, ty, tz = x, y, z or 0
else
tx, ty, tz = x * NAV_SCALE, 0, y * NAV_SCALE
end
local sx, sy, sz = navNearest(nav.nav, tx, ty, tz)
if sx == nil then
return false
end
if navAgentMoveTo(instance.agent, sx, sy, sz) then
instance.walking = true
instance.vars.state = "walk"
return true
end
return false
end
-- Sends a walker somewhere and waits until it arrives, or gives up after a while.
function authorWalkTo(instance, x, y, z)
if authorWalkStart(instance, x, y, z) then
local till = authorTime() + WALK_TIMEOUT
authorWait(function() return (not instance.alive) or (not instance.walking) or (authorTime() > till) end)
end
end
function authorDistance3D(a, b)
local ax, ay, az = authorPosition(a)
local bx, by, bz = authorPosition(b)
return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2 + (az - bz) ^ 2)
end
function authorSpawnAtPointer(typeName, player)
local x, y, z = authorPointerWorld(player)
if x ~= nil then
return authorSpawn(typeName, x, y, z)
end
return nil
end
-- The world point under a player's pointer: the overlay point in 2D, the floor the ray hits in 3D.
function authorPointerWorld(player)
local px, py, on = authorPointer(player)
if not on then
return nil
end
if not AUTHOR_3D then
return px, py, 0
end
local x0, y0, z0 = sceneUnproject(px, py, 0)
local x1, y1, z1 = sceneUnproject(px, py, 10)
local node, hx, hy, hz = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH)
if node == nil then
return nil
end
return hx, hy, hz
end
function authorWalkToPointer(instance, player)
local x, y, z = authorPointerWorld(player)
if x ~= nil then
authorWalkTo(instance, x, y, z)
end
end
function authorWalkToHotspot(instance, target)
if (target == nil) or (target.hotspot == nil) then
return
end
local p = target.hotspot
local x, y, z = authorPosition(target)
authorWalkTo(instance, p.walkX or x, p.walkY or y, p.walkZ or z)
end
function authorFace(instance, direction, target)
if instance == nil then
return
end
if target then
local x, y, z = authorPosition(instance)
local tx, ty, tz = authorPosition(target)
instance.vars.facing = (tx < x) and "left" or "right"
if instance.model then
nodeLookAt(instance.model, tx, y, tz)
end
elseif direction then
instance.vars.facing = direction
end
end
function authorNavArrived(agent)
local instance = AUTHOR_AGENTS[agent]
if instance and instance.walking then
instance.walking = false
instance.vars.state = "idle"
authorEvent("arrived", { self = instance })
end
end
-- The hotspot under a player's pointer, or nil. The topmost in 2D; what the ray meets in 3D.
function authorHoverInstance(player)
local px, py, on = authorPointer(player)
if not on then
return nil
end
if AUTHOR_3D then
local x0, y0, z0 = sceneUnproject(px, py, 0)
local x1, y1, z1 = sceneUnproject(px, py, 10)
local node = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH)
local hit = node and AUTHOR_BY_NODE[node] or nil
return (hit and hit.hotspot) and hit or nil
end
for at = #AUTHOR_LIVE, 1, -1 do
local instance = AUTHOR_LIVE[at]
if instance.alive and instance.visible and instance.hotspot then
if instance.polygon then
if collidePointPolygon(px, py, instance.polygon) then
return instance
end
else
local x, y, w, h = authorBounds(instance)
if collidePointRect(px, py, x, y, w, h) then
return instance
end
end
end
end
return nil
end
function authorHover(player)
local instance = authorHoverInstance(player)
return instance and (instance.hotspot.name or instance.id) or nil
end
function authorGive(item)
AUTHOR_VARS.inventory = AUTHOR_VARS.inventory or {}
if not authorHas(item) then
AUTHOR_VARS.inventory[#AUTHOR_VARS.inventory + 1] = item
end
end
function authorTake(item)
for at = #(AUTHOR_VARS.inventory or {}), 1, -1 do
if AUTHOR_VARS.inventory[at] == item then
table.remove(AUTHOR_VARS.inventory, at)
end
end
if AUTHOR_VARS.item == item then
AUTHOR_VARS.item = nil
end
end
function authorSetVerb(verb)
AUTHOR_VARS.verb = verb
AUTHOR_VARS.item = nil
end
function authorNextVerb()
local verbs = AUTHOR_GAME.verbs or {}
local at = 0
for index, verb in ipairs(verbs) do
if verb == AUTHOR_VARS.verb then
at = index
end
end
AUTHOR_VARS.verb = verbs[(at % #verbs) + 1]
AUTHOR_VARS.item = nil
end
function authorUseItem(item)
AUTHOR_VARS.verb = "use"
AUTHOR_VARS.item = item
end
-- The sentence line, rebuilt every frame: "use key with door".
local function updateSentence()
if AUTHOR_GAME.verbs == nil then
return
end
local hover = authorHover(1) or ""
local verb = AUTHOR_VARS.verb or AUTHOR_GAME.verbs[1] or ""
local line = verb
AUTHOR_VARS.hover = hover
if AUTHOR_VARS.item then
line = line .. " " .. AUTHOR_VARS.item .. ((hover ~= "") and " with " or "")
elseif hover ~= "" then
line = line .. " "
end
AUTHOR_VARS.sentence = line .. hover
end
-- A press of the pointer's button in a game with verbs: on a hotspot it is the verb event, with
-- the most specific rule winning; elsewhere it is left to the pressed rules (a walk, usually).
local function verbPress(player)
local target = authorHoverInstance(player)
if target == nil then
return false
end
local verb = AUTHOR_VARS.verb or AUTHOR_GAME.verbs[1]
local item = AUTHOR_VARS.item
authorEvent("verb", { self = target, verb = verb, target = target.hotspot.name or target.id, item = item, player = player })
AUTHOR_VARS.verb = AUTHOR_GAME.verbs[1]
AUTHOR_VARS.item = nil
return true
end
-- A word as the parser's words table knows it, or nil.
local function canonical(word)
for name, synonyms in pairs(parserLayer.words or {}) do
if name == word then
return name
end
for _, synonym in ipairs(synonyms) do
if synonym == word then
return name
end
end
end
return nil
end
-- A typed line: verb, noun, and a second noun after a preposition, as the words table knows
-- them. A word it does not know is reported as unknown, so a rule can say so.
function authorSaid(text)
local words = {}
local ignore = {}
local unknown = nil
local known = {}
for _, word in ipairs(parserLayer.ignore or { "the", "a", "an", "at", "to" }) do
ignore[word] = true
end
for word in string.gmatch(string.lower(text), "%a+") do
if not ignore[word] then
words[#words + 1] = word
end
end
for _, word in ipairs(words) do
local name = canonical(word)
if name then
known[#known + 1] = name
elseif unknown == nil then
unknown = word
end
end
AUTHOR_VARS.typed = text
authorEvent("said", { verb = known[1], noun = known[2], second = known[3], unknown = unknown, text = text })
end
-- ===== Dialogue ===============================================================================
--
-- A dialogue is nodes of a line and choices; the compiler hands them over with each choice's
-- condition and actions as functions. talk runs one from its start and waits for it.
function authorDialogues(list)
dialogues = list
end
function authorTalk(name)
local dialogue = dialogues[name]
if dialogue == nil then
debugPrint("Author: no dialogue called '" .. tostring(name) .. "'")
return
end
local node = dialogue.nodes[dialogue.start]
local was = AUTHOR_CONTROLS
AUTHOR_CONTROLS = false
while node ~= nil do
if node.text then
authorSay(((node.who and (node.who .. ": ")) or "") .. node.text, node.seconds)
end
local offered = {}
for index, choice in ipairs(node.choices or {}) do
local key = name .. "/" .. tostring(node.name) .. "/" .. index
if ((choice.when == nil) or choice.when()) and not (choice.once and onceSeen[key]) then
offered[#offered + 1] = { choice = choice, key = key }
end
end
if #offered == 0 then
node = node.next and dialogue.nodes[node.next] or nil
else
talking = { offered = offered, chosen = nil }
authorWait(function() return talking.chosen ~= nil end)
local picked = offered[talking.chosen]
talking = nil
if picked.choice.once then
onceSeen[picked.key] = true
end
if picked.choice.run then
picked.choice.run()
end
node = picked.choice.next and dialogue.nodes[picked.choice.next] or nil
end
end
AUTHOR_CONTROLS = was
end
-- Whether anything is still happening that a player would wait for: a sequence running, a line
-- on screen. A dialogue waiting for a choice is not busy; it is waiting for the player.
function authorBusy()
if talking ~= nil then
return false
end
if sayLine ~= nil then
return true
end
return next(sequences) ~= nil
end
-- The choices a dialogue is offering, or nil.
function authorChoices()
if talking == nil then
return nil
end
local texts = {}
for index, offer in ipairs(talking.offered) do
texts[index] = offer.choice.text
end
return texts
end
-- For tests and for a pointer: pick a choice by number.
function authorChoose(index)
if talking and talking.offered[index] then
talking.chosen = index
end
end
function authorFade(seconds, out)
local from = fadeLevel
local to = out and 1 or 0
local till = authorTime() + seconds
local start = authorTime()
authorWait(function()
local now = authorTime()
fadeLevel = from + (to - from) * math.min(1, (now - start) / seconds)
return now >= till
end)
fadeLevel = to
end
function authorDie(text)
authorSay(text, nil)
authorFade(1, true)
authorRestart()
fadeLevel = 0
end
-- ===== Saving =================================================================================
--
-- The whole game -- which room, where everything in every visited room is, every var, the
-- inventory -- as one string under a slot. Coroutines cannot be saved, so a save taken during a
-- sequence records the world as it stands and the sequence does not resume.
local function sourceOf(value)
if type(value) == "table" then
local parts = {}
for key, item in pairs(value) do
local name = (type(key) == "string") and (key:match("^[%a_][%w_]*$") and key or ("[" .. string.format("%q", key) .. "]")) or ("[" .. tostring(key) .. "]")
parts[#parts + 1] = name .. " = " .. sourceOf(item)
end
return "{ " .. table.concat(parts, ", ") .. " }"
elseif type(value) == "string" then
return string.format("%q", value)
end
return tostring(value)
end
local function snapshotRoom(list)
local entities = {}
for _, instance in ipairs(list) do
if instance.alive then
local x, y, z = authorPosition(instance)
entities[#entities + 1] = { id = instance.id, type = instance.type, x = x, y = y, z = z, vars = instance.vars }
end
end
return entities
end
function authorSaveGame(slot)
local rooms = {}
for _, room in ipairs(AUTHOR_GAME.rooms) do
if room == AUTHOR_ROOM then
rooms[room.name] = snapshotRoom(AUTHOR_LIVE)
elseif room.kept then
rooms[room.name] = snapshotRoom(room.kept)
end
end
saveSet("forge.slot" .. tostring(slot), "return " .. sourceOf({ room = AUTHOR_ROOM.name, vars = AUTHOR_VARS, rooms = rooms, once = onceSeen }))
end
function authorLoadGame(slot)
local text = saveGet("forge.slot" .. tostring(slot))
if text == nil then
return false
end
local chunk = load(text, "save", "t", {})
if chunk == nil then
return false
end
local saved = chunk()
authorRestart()
AUTHOR_VARS = saved.vars
onceSeen = saved.once or {}
for _, room in ipairs(AUTHOR_GAME.rooms) do
room.saved = saved.rooms[room.name]
if room.kept then
for _, instance in ipairs(room.kept) do
nodeDelete(instance.node)
end
room.kept = nil
end
end
for at = #AUTHOR_LIVE, 1, -1 do
authorDestroy(AUTHOR_LIVE[at])
end
AUTHOR_ROOM = nil
authorGoTo(saved.room)
return true
end
-- ===== Sequences ==============================================================================
--
-- An action that takes time yields from the rule's coroutine until it is done; authorWait is what
-- it yields through. Outside a coroutine (a rule the compiler saw no waiting action in) the wait
-- is skipped and the action's effect stands, so nothing breaks when a rule is edited by hand.
function authorWait(predicate)
if not coroutine.isyieldable() then
return
end
while not predicate() do
coroutine.yield()
end
end
function authorWaitSeconds(seconds)
local till = authorTime() + seconds
authorWait(function() return authorTime() >= till end)
end
function authorSay(text, seconds)
text = tostring(text or "")
seconds = seconds or (SAY_SECONDS_MIN + #text * SAY_PER_CHAR)
sayLine = { text = text, till = authorTime() + seconds }
if hud then
guiSetValue(hud.gui, hud.document, "say", text)
end
authorWait(function() return authorTime() >= sayLine.till end)
end
-- ===== Rules ==================================================================================
--
-- The compiler hands over the rules once; each is { on, filter, each, waits, controls, run }. run
-- takes (self, other, event) and does the conditions and actions. A rule with waits runs as a
-- coroutine kept until it finishes; the same rule on the same instance does not start again while
-- one is running unless the rule says interrupt.
function authorRules(list)
rules = {}
pairsToTest = {}
for _, rule in ipairs(list) do
rules[rule.on] = rules[rule.on] or {}
rules[rule.on][#rules[rule.on] + 1] = rule
if rule.on == "collision" then
pairsToTest[#pairsToTest + 1] = { a = rule.filter.a, b = rule.filter.b }
end
end
-- The first room was built by authorBegin, before this; what it raised -- roomStart, a spawn
-- for every entity placed in it -- is delivered now that there are rules to hear it.
rulesReady = true
for _, held in ipairs(pending) do
authorEvent(held.name, held.event)
end
pending = {}
end
local runRule
-- Runs a rule for an event: once, with the event's own instance as self, or once per instance
-- of the rule's type when the event has none. Answers whether it ran for anything.
local function dispatch(rule, event)
if rule.each and (event.self == nil) then
local any = false
for _, instance in ipairs(authorEach(rule.each)) do
if runRule(rule, instance, event.other, event) then
any = true
end
end
return any
end
return runRule(rule, event.self, event.other, event)
end
-- Runs a rule. Answers whether its conditions held -- a sequence that yielded has passed them,
-- since the conditions come before the first action.
function runRule(rule, self, other, event)
if not rule.waits then
return rule.run(self, other, event) == true
end
local key = tostring(rule) .. "/" .. (self and self.id or "")
if sequences[key] and not rule.interrupt then
return false
end
local co = coroutine.create(function()
local held
if rule.controls == false then
AUTHOR_CONTROLS = false
end
held = rule.run(self, other, event)
if rule.controls == false then
AUTHOR_CONTROLS = true
end
return held
end)
sequences[key] = co
local ok, held = coroutine.resume(co)
if not ok then
debugPrint("Author: " .. tostring(held))
sequences[key] = nil
return false
end
if coroutine.status(co) == "dead" then
sequences[key] = nil
return held == true
end
return true
end
local function matches(rule, event)
for name, want in pairs(rule.filter) do
if name == "type" then
if (event.self == nil) or (event.self.type ~= want) then
return false
end
elseif name == "a" then
if (event.self == nil) or (event.self.type ~= want) then
return false
end
elseif name == "b" then
if (event.other == nil) or (event.other.type ~= want) then
return false
end
elseif event[name] ~= want then
return false
end
end
-- each on an event about an instance narrows it to that type; on an event about nothing in
-- particular (a key, a room, the disc) it fans the rule out over every instance of the type.
if rule.each and (event.self ~= nil) and (event.self.type ~= rule.each) then
return false
end
if rule.room and ((AUTHOR_ROOM == nil) or (AUTHOR_ROOM.name ~= rule.room)) then
return false
end
return true
end
-- Something happened. event carries self, other, and whatever the event has to say. An
-- exclusive event -- a verb, a typed line -- runs only the most specific rules that match: "use
-- key on door" beats "use anything on door" beats "use".
function authorEvent(name, event)
local list = rules[name]
local entry = AUTHOR.events[name]
if gameOver and (name ~= "gameOver") and (name ~= "pressed") then
return
end
if event.self then
authorBehaviourEvent(event.self, name, event)
end
if not rulesReady then
pending[#pending + 1] = { name = name, event = event }
return
end
if list == nil then
return
end
if entry and entry.exclusive then
-- Tiers by how many of the event's parameters a rule names, a rule with conditions
-- ranking above one without among the same parameters; the most specific tier whose
-- conditions hold is the one that acts, and the rest are left alone.
local tiers = {}
for _, rule in ipairs(list) do
if matches(rule, event) then
local score = rule.guarded and 1 or 0
for _ in pairs(rule.filter) do
score = score + 2
end
tiers[score] = tiers[score] or {}
tiers[score][#tiers[score] + 1] = rule
end
end
for score = 7, 0, -1 do
local ran = false
for _, rule in ipairs(tiers[score] or {}) do
if dispatch(rule, event) then
ran = true
end
end
if ran then
return
end
end
return
end
for _, rule in ipairs(list) do
if matches(rule, event) then
dispatch(rule, event)
end
end
end
-- The behaviours hear about their instance's events too: a sound plays its clip on hit.
function authorBehaviourEvent(instance, name, event)
for _, b in ipairs(instance.behaviours) do
local behaviour = AUTHOR.behaviours[b.kind]
if behaviour and behaviour.on then
behaviour.on(instance, name, event or {})
end
end
end
local function runFrameRules()
local list = rules.frame
if (list == nil) or gameOver then
return
end
for _, rule in ipairs(list) do
if (rule.room == nil) or (AUTHOR_ROOM and AUTHOR_ROOM.name == rule.room) then
if rule.each then
for _, instance in ipairs(authorEach(rule.each)) do
runRule(rule, instance, nil, {})
end
else
runRule(rule, nil, nil, {})
end
end
end
end
local function resumeSequences()
local running = {}
-- A snapshot, because a resumed rule may start another sequence, and adding to a table while
-- pairs walks it is not allowed.
for key, co in pairs(sequences) do
running[#running + 1] = { key = key, co = co }
end
for _, entry in ipairs(running) do
if coroutine.status(entry.co) == "dead" then
sequences[entry.key] = nil
elseif sequences[entry.key] == entry.co then
local ok, err = coroutine.resume(entry.co)
if not ok then
debugPrint("Author: " .. tostring(err))
sequences[entry.key] = nil
elseif coroutine.status(entry.co) == "dead" then
sequences[entry.key] = nil
end
end
end
end
-- ===== Rooms ==================================================================================
local function roomNamed(name)
for _, room in ipairs(AUTHOR_GAME.rooms) do
if room.name == name then
return room
end
end
return nil
end
local function buildRoom(room)
local from = room.saved or room.entities or {}
room.saved = nil
for _, entry in ipairs(from) do
make(entry.type, entry.x, entry.y, entry.z, entry)
end
authorRoomNav()
end
-- Leaves the current room, keeping its instances if it wants to be remembered. Kept instances
-- keep their nodes; they are only taken out of the live list and drawn no more.
local function leaveRoom()
if AUTHOR_ROOM == nil then
return
end
authorEvent("roomEnd", { room = AUTHOR_ROOM.name })
if AUTHOR_ROOM.reset then
for at = #AUTHOR_LIVE, 1, -1 do
authorDestroy(AUTHOR_LIVE[at])
end
AUTHOR_ROOM.kept = nil
else
AUTHOR_ROOM.kept = AUTHOR_LIVE
for _, instance in ipairs(AUTHOR_LIVE) do
nodeSetVisible(instance.node, false)
end
end
-- Agents belong to the room's mesh; a walker makes a new one where it arrives.
for _, instance in ipairs(AUTHOR_LIVE) do
if instance.agent then
navAgentDelete(instance.agent)
AUTHOR_AGENTS[instance.agent] = nil
instance.agent = nil
if instance.navNode and (instance.navNode ~= instance.node) then
nodeDelete(instance.navNode)
instance.navNode = nil
end
end
end
AUTHOR_LIVE = {}
AUTHOR_BY_ID = {}
timers = {}
-- Sequences stay: the cut-scene that walked through the door is the one changing rooms, and
-- it has a fade-in and a score still to do on the other side.
end
function authorGoTo(name, instance, x, y, z, at)
local room = roomNamed(name)
if room == nil then
debugPrint("Author: no room called '" .. tostring(name) .. "'")
return
end
local carried = instance and instance.alive and instance or nil
if carried then
-- The one that travels comes out of the room it leaves before it is stashed.
for at = #AUTHOR_LIVE, 1, -1 do
if AUTHOR_LIVE[at] == carried then
table.remove(AUTHOR_LIVE, at)
end
end
end
leaveRoom()
AUTHOR_ROOM = room
if room.kept then
AUTHOR_LIVE = room.kept
room.kept = nil
for _, kept in ipairs(AUTHOR_LIVE) do
AUTHOR_BY_ID[kept.id] = kept
nodeSetVisible(kept.node, true)
if kept.camera then
cameraNode = kept.node
cameraSet(kept.node)
end
end
else
buildRoom(room)
end
if carried then
-- An instance of the same id already in the room gives way to the one arriving.
local standing = AUTHOR_BY_ID[carried.id]
if standing and (standing ~= carried) then
authorDestroy(standing)
end
AUTHOR_LIVE[#AUTHOR_LIVE + 1] = carried
AUTHOR_BY_ID[carried.id] = carried
AUTHOR_BY_NODE[carried.node] = carried
for _, node in ipairs(carried.nodes) do
AUTHOR_BY_NODE[node] = carried
end
if at and AUTHOR_BY_ID[at] then
local ax, ay, az = authorPosition(AUTHOR_BY_ID[at])
authorMoveTo(carried, ax, ay, az)
elseif x ~= nil then
authorMoveTo(carried, x, y, z)
end
end
for _, spawned in ipairs(AUTHOR_LIVE) do
if spawned ~= carried and not spawned.begun then
spawned.begun = true
authorEvent("spawn", { self = spawned })
end
end
authorWavesReset()
authorEvent("roomStart", { room = name })
end
function authorRestart()
for _, room in ipairs(AUTHOR_GAME.rooms) do
if room.kept then
for _, instance in ipairs(room.kept) do
nodeDelete(instance.node)
end
room.kept = nil
end
end
for at = #AUTHOR_LIVE, 1, -1 do
authorDestroy(AUTHOR_LIVE[at])
end
AUTHOR_ROOM = nil
cameraNode = nil
sequences = {}
AUTHOR_VARS = copyOf(AUTHOR_GAME.vars or {})
AUTHOR_VARS.score = AUTHOR_VARS.score or 0
if AUTHOR_GAME.verbs then
AUTHOR_VARS.verb = AUTHOR_GAME.verbs[1]
AUTHOR_VARS.inventory = AUTHOR_VARS.inventory or {}
end
AUTHOR_TYPING = ""
talking = nil
fadeLevel = 0
results = nil
AUTHOR_VARS.best = saveGet(BEST_KEY, 0)
onceSeen = {}
gameOver = false
sayLine = nil
AUTHOR_STARTED = singeGetTicks()
lastTime = 0
lastFrame = -1
authorGoTo(AUTHOR_GAME.rooms[1].name)
end
-- ===== Pointers and guns ======================================================================
-- A player's pointer in overlay coordinates, and whether it is on the picture. A test can set it.
function authorPointer(player)
local device = (player or 1) - 1
if pointers[player or 1] then
return pointers[player or 1].x, pointers[player or 1].y, pointers[player or 1].on
end
if device >= mouseHowMany() then
device = 0
end
local x, y = mouseGetPosition(device)
return x, y, (x >= 0) and (y >= 0) and (x < overlayGetWidth()) and (y < overlayGetHeight())
end
-- For tests: where a player's pointer is, and whether it counts as on the picture.
function authorSetPointer(player, x, y, on)
if x == nil then
pointers[player] = nil
else
pointers[player] = { x = x, y = y, on = (on ~= false) }
end
end
function authorPointerIn(instance, player)
if instance == nil then
return false
end
local px, py, on = authorPointer(player)
local x, y, w, h = authorHitShape(instance)
return on and (x ~= nil) and collidePointRect(px, py, x, y, w, h)
end
function authorPointerOffscreen(player)
local _, _, on = authorPointer(player)
return not on
end
-- What a gun can hit an instance in: its track's box at this frame when it has a hitbox
-- behaviour, its look's box otherwise. nil when the track has no box now.
function authorHitShape(instance)
if instance.hitTrack then
local box = authorTrackBox(instance.hitTrack)
if box == nil then
return nil
end
return box.x, box.y, box.w, box.h
end
return authorBounds(instance)
end
-- A shot from a player's gun. The topmost instance under the pointer is hit; nothing is a miss.
local function fire(gun)
local player = gun.vars.player
local px, py, on = authorPointer(player)
if gun.gun.aim == "centre" then
px, py, on = overlayGetWidth() / 2, overlayGetHeight() / 2, true
end
if gun.vars.ammo <= 0 then
authorBehaviourEvent(gun, "empty")
if (gun.gun.reload == "offscreen") and not on then
authorReload(gun)
end
return
end
if (gun.gun.reload == "offscreen") and not on then
authorReload(gun)
return
end
gun.vars.ammo = gun.vars.ammo - 1
authorBehaviourEvent(gun, "fire")
if on and AUTHOR_3D then
-- A ray through the pointer, from the near plane out: what it hits is a body, and the
-- body's node says which instance -- and which zone of it, when it has zones.
local x0, y0, z0 = sceneUnproject(px, py, 0)
local x1, y1, z1 = sceneUnproject(px, py, 10)
local node, hx, hy, hz = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH)
local target = node and authorInstanceOf(node) or nil
local casts = 0
-- What the ray meets first may be something a shot passes through: the hero's own body
-- in first person, a trigger, a wall of glass. The cast goes on from just past it, a few
-- times, before the shot counts as a miss.
while node and not (target and target.isTarget) and (casts < 4) do
local dx, dy, dz = x1 - x0, y1 - y0, z1 - z0
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
casts = casts + 1
x0, y0, z0 = hx + dx / len * 0.05, hy + dy / len * 0.05, hz + dz / len * 0.05
x1, y1, z1 = x0 + dx, y0 + dy, z0 + dz
node, hx, hy, hz = physicsRaycast(x0, y0, z0, dx, dy, dz, RAY_REACH)
target = node and authorInstanceOf(node) or nil
end
if target and target.alive and target.isTarget then
-- The body box is what the ray meets first; a zone inside it -- the head -- is found by
-- how close the ray passes to the zone's centre.
local part = "body"
local dx, dy, dz = x1 - x0, y1 - y0, z1 - z0
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
dx, dy, dz = dx / len, dy / len, dz / len
for _, zone in ipairs(target.zones or {}) do
local zx, zy, zz = nodeGetWorldPosition(zone.node)
local t = (zx - x0) * dx + (zy - y0) * dy + (zz - z0) * dz
local cx, cy, cz = x0 + dx * t - zx, y0 + dy * t - zy, z0 + dz * t - zz
if (t > 0) and (math.sqrt(cx * cx + cy * cy + cz * cz) <= zone.reach) then
part = zone.name
end
end
authorEvent("hit", { self = target, other = gun, player = player, x = hx, y = hy, z = hz, part = part })
return
end
elseif on then
for at = #AUTHOR_LIVE, 1, -1 do
local target = AUTHOR_LIVE[at]
if target.alive and target.visible and target.isTarget then
local x, y, w, h = authorHitShape(target)
if (x ~= nil) and collidePointRect(px, py, x, y, w, h) then
authorEvent("hit", { self = target, other = gun, player = player, x = px, y = py })
return
end
end
end
end
authorEvent("miss", { self = gun, player = player, offscreen = not on, x = px, y = py })
end
-- ===== Tracks =================================================================================
--
-- A track is a list of keys by frame or time. A box track answers the box at the current key
-- position, interpolated between keys, nil outside them.
function authorTrackNamed(name)
for _, track in ipairs(AUTHOR_ROOM and AUTHOR_ROOM.tracks or {}) do
if track.name == name then
return track
end
end
for _, track in ipairs(AUTHOR_GAME.tracks or {}) do
if track.name == name then
return track
end
end
return nil
end
local function trackNow(track)
if track.key == "frame" then
return discGetFrame()
end
return authorTime()
end
-- The interpolated box of a track at a position along it.
function authorTrackBoxAt(track, at)
local keys = track.boxes or {}
local before, after
for _, key in ipairs(keys) do
if key.at <= at and ((before == nil) or (key.at > before.at)) then
before = key
end
if key.at >= at and ((after == nil) or (key.at < after.at)) then
after = key
end
end
if (before == nil) or (after == nil) then
return nil
end
if (before == after) or (after.at == before.at) then
return { x = before.x, y = before.y, w = before.w, h = before.h }
end
local t = (at - before.at) / (after.at - before.at)
return {
x = before.x + (after.x - before.x) * t,
y = before.y + (after.y - before.y) * t,
w = before.w + (after.w - before.w) * t,
h = before.h + (after.h - before.h) * t
}
end
function authorTrackBox(name)
local track = authorTrackNamed(name)
if track == nil then
return nil
end
return authorTrackBoxAt(track, trackNow(track))
end
-- ===== Waves ==================================================================================
--
-- A track of spawns: keys by time, by disc frame, or by a rail camera's stop, each making count
-- instances of a type, one every so many seconds, at an entity named in from -- at each of a
-- comma-separated list of them in turn -- or at the key's own point. A key begins once per
-- visit to the room.
local waves = {} -- The waves under way: { key, made, wait }.
local started = {} -- The keys begun this visit, by key.
local function waveBegin(key)
started[key] = true
waves[#waves + 1] = { key = key, made = 0, wait = 0 }
end
-- Where a wave's next instance goes.
local function wavePlace(wave)
local from = wave.key.from
local names = {}
if type(from) == "table" then
names = from
elseif type(from) == "string" then
for name in from:gmatch("[^,%s]+") do
names[#names + 1] = name
end
end
if #names > 0 then
local x, y, z = authorTargetPosition(names[(wave.made % #names) + 1])
if x then
return x, y, z
end
end
return wave.key.x or 0, wave.key.y or 0, wave.key.z or 0
end
-- Every track of spawns, the room's and the game's.
local function waveTracks()
local found = {}
for _, list in ipairs({ AUTHOR_ROOM and AUTHOR_ROOM.tracks or {}, AUTHOR_GAME.tracks or {} }) do
for _, track in ipairs(list) do
if track.spawns then
found[#found + 1] = track
end
end
end
return found
end
local function updateWaves(dt)
-- Keys by time or by frame begin when the track passes them.
for _, track in ipairs(waveTracks()) do
if track.key ~= "stop" then
local now = trackNow(track)
for _, key in ipairs(track.spawns) do
if not started[key] and (type(key.at) == "number") and (now >= key.at) then
waveBegin(key)
end
end
end
end
-- Each wave under way makes its next instance when its wait is up.
for index = #waves, 1, -1 do
local wave = waves[index]
wave.wait = wave.wait - dt
if wave.wait <= 0 then
local x, y, z = wavePlace(wave)
authorSpawn(wave.key.type, x, y, z)
wave.made = wave.made + 1
wave.wait = wave.key.every or 0
if wave.made >= (wave.key.count or 1) then
table.remove(waves, index)
end
end
end
end
-- A rail camera reached a stop: the waves keyed by that stop begin.
function authorWavesAtStop(name)
for _, track in ipairs(waveTracks()) do
if track.key == "stop" then
for _, key in ipairs(track.spawns) do
if not started[key] and (key.at == name) then
waveBegin(key)
end
end
end
end
end
-- A room begun: its waves start over.
function authorWavesReset()
waves = {}
started = {}
end
-- ===== Timers and the disc ====================================================================
local function updateTimers()
local now = authorTime()
for at = #timers, 1, -1 do
local timer = timers[at]
-- A timer's rule may destroy an instance and take its timers out from under this loop.
if timer == nil then
-- Nothing left at this index.
elseif timer.instance.alive and (now >= timer.at) then
if timer.every then
timer.at = now + timer.every
else
table.remove(timers, at)
end
authorEvent("timer", { self = timer.instance, name = timer.name })
elseif not timer.instance.alive then
table.remove(timers, at)
end
end
end
local function updateDisc()
if (discGetState == nil) or (AUTHOR_GAME.disc == false) then
return
end
local frame = discGetFrame()
local list = rules.frameReached
if list and (lastFrame >= 0) and (frame > lastFrame) then
for _, rule in ipairs(list) do
local want = rule.filter.frame
if want and (want > lastFrame) and (want <= frame) then
runRule(rule, nil, nil, { frame = frame })
end
end
end
lastFrame = frame
end
-- ===== Collisions =============================================================================
--
-- The rules say which types they care about; only those pairs are tested, and a pair that goes on
-- touching is reported once.
local touchingNow = {}
local function checkCollisions()
local seen = {}
for _, pair in ipairs(pairsToTest) do
for _, a in ipairs(authorEach(pair.a)) do
for _, b in ipairs(authorEach(pair.b)) do
if (a ~= b) and a.alive and b.alive and authorTouching(a, b) then
local key = a.id .. "|" .. b.id
seen[key] = true
if not touchingNow[key] then
authorEvent("collision", { self = a, other = b })
end
end
end
end
end
touchingNow = seen
end
-- ===== HUD ====================================================================================
local function updateHud()
if hud == nil then
return
end
for id, var in pairs(hud.bind) do
local value = AUTHOR_VARS[var]
if type(value) == "table" then
value = table.concat(value, ", ")
else
value = tostring(value or "")
end
if hud.shown[id] ~= value then
hud.shown[id] = value
guiSetValue(hud.gui, hud.document, id, value)
end
end
if sayLine and (authorTime() >= sayLine.till) then
sayLine = nil
guiSetValue(hud.gui, hud.document, "say", "")
end
end
-- ===== The frame ==============================================================================
-- Builds the game from its description: layers, vars, and the first room. The compiled game
-- calls this once, then authorRules with what it compiled, then authorFrame every frame.
function authorBegin(game)
-- A font, because the text look draws with fontPrint and fontPrint ends the game when none is
-- selected. 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
MOUSE_LEFT = SWITCH_BUTTON3
AUTHOR_GAME = game
-- The overlay the game was drawn for. Without it the engine's default stands, which over a
-- disc is the disc's own size.
if game.size then
overlaySetResolution(game.size.w, game.size.h)
end
game.types = game.types or {}
game.rooms = game.rooms or { { name = "main", entities = {} } }
game.disc = false
for _, layer in ipairs(game.layers or {}) do
local kind = AUTHOR.layers[layer.kind]
if kind == nil then
debugPrint("Author: no layer called '" .. tostring(layer.kind) .. "'")
else
if layer.kind == "disc" then
game.disc = true
end
kind.begin(layer)
end
end
-- What is behind the overlay: the disc or the scene when the game has one, and a dark ground
-- of its own otherwise, so a flat game never plays over whatever video the engine was given.
if game.disc or AUTHOR_3D then
colorBackground(0, 0, 0, 0)
else
colorBackground(AUTHOR_GROUND.r, AUTHOR_GROUND.g, AUTHOR_GROUND.b, 255)
end
if (game.players or 1) > 1 then
mouseSetMode(MOUSE_MANY)
end
authorRestart()
end
-- One frame: timers, behaviours, sequences, the frame rules, collisions, the HUD, and everything
-- drawn in the order it was made. The generated game calls this from onOverlayUpdate.
function authorFrame()
local now = authorTime()
local dt = math.min(now - lastTime, FRAME_CAP)
lastTime = now
updateTimers()
updateDisc()
updateWaves(dt)
for _, instance in ipairs(AUTHOR_LIVE) do
if instance.alive then
for _, b in ipairs(instance.behaviours) do
local behaviour = AUTHOR.behaviours[b.kind]
if behaviour and behaviour.step then
behaviour.step(instance, dt)
end
end
end
end
resumeSequences()
runFrameRules()
checkCollisions()
updateSentence()
updateHud()
updateBezel()
if masterReady and (masterPump ~= nil) then
masterPump()
end
pressedNow = {}
AUTHOR_MOUSE.dx = 0
AUTHOR_MOUSE.dy = 0
overlayClear()
if AUTHOR_ROOM and AUTHOR_ROOM.depthSort then
-- Further up the picture is further away, so what is lower is drawn last, over it.
local order = {}
for at, instance in ipairs(AUTHOR_LIVE) do
order[at] = instance
instance.order = at
end
table.sort(order, function(a, b)
local ay, by = authorDepthY(a), authorDepthY(b)
if ay == by then
return a.order < b.order
end
return ay < by
end)
for _, instance in ipairs(order) do
if instance.alive and instance.visible then
AUTHOR.looks[instance.look.kind].draw(instance)
end
end
else
for _, instance in ipairs(AUTHOR_LIVE) do
if instance.alive and instance.visible then
AUTHOR.looks[instance.look.kind].draw(instance)
end
end
end
-- Particles in a 2D room are drawn on request, unlike the scene's.
if not AUTHOR_3D then
for _, instance in ipairs(AUTHOR_LIVE) do
if instance.alive and instance.emitter then
emitterDraw(instance.emitter)
end
end
end
if talking then
-- The choices, numbered, low on the picture (or in the hud's choices element).
local lines = {}
for index, offer in ipairs(talking.offered) do
lines[#lines + 1] = index .. ". " .. offer.choice.text
end
if hud then
guiSetValue(hud.gui, hud.document, "choices", table.concat(lines, "\n"))
else
colorForeground(255, 230, 120, 255)
for index, line in ipairs(lines) do
fontPrint(40, overlayGetHeight() - 40 - (#lines - index + 1) * 22, line)
end
end
elseif hud and hud.choicesShown then
guiSetValue(hud.gui, hud.document, "choices", "")
end
if hud then
hud.choicesShown = (talking ~= nil)
end
if parserLayer then
colorForeground(235, 235, 245, 255)
fontPrint(12, overlayGetHeight() - 30, (parserLayer.prompt or ">") .. AUTHOR_TYPING .. "_")
end
if results then
local middle = overlayGetHeight() / 2
colorForeground(255, 230, 120, 255)
fontPrint(overlayGetWidth() / 2 - 60, middle - 30, "GAME OVER")
colorForeground(235, 235, 245, 255)
fontPrint(overlayGetWidth() / 2 - 60, middle, "score " .. results.score)
fontPrint(overlayGetWidth() / 2 - 60, middle + 24, "best " .. results.best)
end
if fadeLevel > 0 then
colorForeground(0, 0, 0, math.floor(255 * fadeLevel))
for row = 0, overlayGetHeight() - 1 do
overlayLine(0, row, overlayGetWidth(), row)
end
end
if flashLine then
if now >= flashLine.till then
flashLine = nil
else
local alpha = math.floor(255 * (flashLine.till - now) / flashLine.seconds)
colorForeground(flashLine.r, flashLine.g, flashLine.b, alpha)
for row = 0, overlayGetHeight() - 1, 2 do
overlayLine(0, row, overlayGetWidth(), row)
end
end
end
if sayLine and (hud == nil) then
if now >= sayLine.till then
sayLine = nil
else
colorForeground(255, 255, 255, 255)
fontPrint(overlayGetWidth() / 2 - #sayLine.text * 4, overlayGetHeight() - 60, sayLine.text)
end
end
if hud then
guiDraw(hud.gui)
end
end
-- ===== Input ==================================================================================
--
-- Held state, because rules ask "is this key down" rather than "was it just pressed", and edges,
-- because some do. The generated game points the engine's callbacks straight at these.
AUTHOR_KEYS = {}
AUTHOR_SWITCHES = {}
function authorKeyDown(keysym, scancode)
AUTHOR_KEYS[scancode] = true
pressedNow[scancode] = true
-- A dialogue's choices take the number keys first.
if talking then
for index, name in ipairs(CHOICE_KEYS) do
if scancode == SCANCODE[name].value then
authorChoose(index)
return
end
end
end
-- The parser takes the typing keys.
if parserLayer and AUTHOR_CONTROLS then
if scancode == SCANCODE.RETURN.value then
local line = AUTHOR_TYPING
AUTHOR_TYPING = ""
if line ~= "" then
authorSaid(line)
end
return
elseif scancode == SCANCODE.BACKSPACE.value then
AUTHOR_TYPING = string.sub(AUTHOR_TYPING, 1, -2)
return
elseif (keysym >= 32) and (keysym < 127) then
AUTHOR_TYPING = AUTHOR_TYPING .. string.char(keysym)
return
end
end
if AUTHOR_CONTROLS then
local px, py = authorPointer(1)
authorEvent("pressed", { key = scancode, x = px, y = py })
end
end
function authorKeyUp(keysym, scancode)
AUTHOR_KEYS[scancode] = nil
authorEvent("released", { key = scancode })
end
-- In MODE_FULL the engine hands every key to onInputPressed as its keysym, on the same channel
-- as the switches, and four keysyms are switch numbers too: BACKSPACE is 8 (SWITCH_BUTTON3),
-- TAB 9 (SWITCH_COIN1), RETURN 13, and ESCAPE 27. The key is down at that moment and the switch
-- is not, which tells them apart.
local KEY_MASQUERADES = { [8] = "BACKSPACE", [9] = "TAB", [13] = "RETURN", [27] = "ESCAPE" }
function authorSwitchIsKey(what)
local name = KEY_MASQUERADES[what]
return (name ~= nil) and keyboardIsDown(SCANCODE[name].value)
end
function authorSwitchDown(what)
if authorSwitchIsKey(what) then
return
end
AUTHOR_SWITCHES[what] = true
pressedNow[what] = true
if not AUTHOR_CONTROLS then
return
end
for _, instance in ipairs(AUTHOR_LIVE) do
if instance.alive and instance.gun and (what == (instance.trigger or MOUSE_LEFT)) then
fire(instance)
end
end
if talking and (what == MOUSE_LEFT) then
-- A click picks the choice under the pointer, by its line.
local _, py = authorPointer(1)
local count = #talking.offered
local index = count - math.floor((overlayGetHeight() - 40 - py) / 22)
authorChoose(math.max(1, math.min(count, index)))
return
end
if AUTHOR_GAME.verbs and (what == MOUSE_LEFT) and verbPress(1) then
return
end
local px, py = authorPointer(1)
authorEvent("pressed", { switch = what, x = px, y = py })
end
function authorSwitchUp(what)
if authorSwitchIsKey(what) then
return
end
AUTHOR_SWITCHES[what] = nil
authorEvent("released", { switch = what })
end