--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * 02110-1301, USA. * * --]] -- The runtime an authored game is compiled against (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. -- kinds what a thing is: a look, behaviours, and vars. Everything placed or spawned is -- an instance of a kind. -- 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 -- kind a kind'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 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 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 kind, in draw order. function authorEach(kind) local list = {} for _, instance in ipairs(AUTHOR_LIVE) do if instance.alive and (instance.kind == kind) then list[#list + 1] = instance end end return list end function authorCount(kind) return #authorEach(kind) 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, kind, 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 == "kind") 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 = authorDepthScale(y) 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. 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. 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, file) else sprites[key] = spriteLoad(file) end end return sprites[key] end local function soundFor(file) if sounds[file] == nil then sounds[file] = soundLoad(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, 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(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(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" }, 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 = authorDepthScale(y) 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" }, 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 = authorDepthScale(y) * (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, instance.look.file) or spriteLoad(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(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" }, 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, 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" }, 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 / 2, h / 2, w / 2) 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" }, 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 / 2, h / 2, w / 2) 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 / 2, h / 2, w / 2) 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" }, 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.", 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 playerNew(instance.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h) end, step = function(instance) -- The rules say which way; this clears it each frame so releasing a key stops the run. playerMove(instance.node, instance.vars.drive * instance.speed) instance.vars.drive = 0 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 kind from an offset while the var fire is set (or always, with auto), no faster than the rate.", params = { spawns = "kind", 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 kind 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 = "kind", every = "number", max = "number", total = "number", points = "table", pick = "string" }, 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) end end, on = function(instance, event) local file = instance.sounds[event] if file and (event ~= "spawn") then authorPlaySound(file, instance.sounds.volume) 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" }, 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" }, 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" }, 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 kind within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself.", params = { targets = "kind", range = "number", rate = "number", spawns = "kind", 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" }, 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 kind a began touching one of kind b (self is a, other is b).", filter = { a = "kind", b = "kind" } } AUTHOR.events.hit = { help = "A gun shot landed on self; other is the gun.", filter = { kind = "kind", 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 = { kind = "kind" } } AUTHOR.events.spawn = { help = "self has just been made.", filter = { kind = "kind" } } AUTHOR.events.timer = { help = "A timer on self went off; event.name says which.", filter = { name = "string", kind = "kind" } } 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 kind b entered a trigger of kind a (self is the trigger, other what entered).", filter = { a = "kind", b = "kind" } } AUTHOR.events.leave = { help = "An instance of kind b left a trigger of kind a.", filter = { a = "kind", b = "kind" } } AUTHOR.events.arrived = { help = "A seeking instance reached its target.", filter = { kind = "kind" } } 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 = { kind = "kind" } } AUTHOR.events.animationDone = { help = "A clip that does not loop ended on self; event.state says which state it was for.", filter = { kind = "kind", state = "state" } } -- ===== 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" }, emit = function(p) return "(authorHover(" .. (p.player or "1") .. ") == " .. 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" }, emit = function(p) return "authorOnce(" .. p.tag .. ", self, " .. (p.scope or "nil") .. ")" 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" }, emit = function(p) return "authorMoveTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" 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" }, emit = function(p) return "authorSetVar(" .. (p.entity or "nil") .. ", " .. 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" }, emit = function(p) return "authorAddVar(" .. (p.entity or "nil") .. ", " .. 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 kind at a point.", params = { kind = "kind", x = "number", y = "number", z = "number" }, emit = function(p) return "authorSpawn(" .. p.kind .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" 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" }, emit = function(p) return "authorPlaySound(" .. p.file .. ", " .. (p.volume or "100") .. ")" 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" }, emit = function(p) return "authorEmit(" .. p.entity .. ", " .. (p.count or "20") .. ", " .. (p.r or "255") .. ", " .. (p.g or "200") .. ", " .. (p.b or "60") .. ", " .. (p.speed or "150") .. ")" 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" }, waits = true, emit = function(p) return "authorSay(" .. p.text .. ", " .. (p.seconds or "nil") .. ")" 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" }, emit = function(p) return "authorFlash(" .. (p.r or "255") .. ", " .. (p.g or "255") .. ", " .. (p.b or "255") .. ", " .. (p.seconds or "0.2") .. ")" 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" }, emit = function(p) return "authorGoTo(" .. p.room .. ", " .. (p.entity or "nil") .. ", " .. (p.x or "nil") .. ", " .. (p.y or "nil") .. ", " .. (p.z or "nil") .. ", " .. (p.at or "nil") .. ")" 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" }, emit = function(p) return "authorPlayAnimation(" .. p.entity .. ", " .. p.clip .. ", " .. (p.loop or "false") .. ")" 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" }, emit = function(p) return "authorPush(" .. p.entity .. ", " .. (p.x or "0") .. ", " .. (p.y or "0") .. ", " .. (p.z or "0") .. ", " .. (p.bone or "nil") .. ")" end } AUTHOR.actions.shake = { help = "Shake the camera for a moment.", params = { amount = "number", seconds = "number" }, emit = function(p) return "authorShake(" .. (p.amount or "0.3") .. ", " .. (p.seconds or "0.4") .. ")" 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 kind where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D.", params = { kind = "kind", player = "number" }, emit = function(p) return "authorSpawnAtPointer(" .. p.kind .. ", " .. (p.player or "1") .. ")" 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" }, waits = true, emit = function(p) return "authorWalkTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" end } AUTHOR.actions.walkToPointer = { help = "Send a walker where the player's pointer is, and wait.", params = { entity = "entity", player = "number" }, waits = true, emit = function(p) return "authorWalkToPointer(" .. p.entity .. ", " .. (p.player or "1") .. ")" 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" }, emit = function(p) return "authorFace(" .. p.entity .. ", " .. (p.direction or "nil") .. ", " .. (p.target or "nil") .. ")" 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" }, waits = true, emit = function(p) return "authorFade(" .. (p.seconds or tostring(FADE_DEFAULT)) .. ", " .. (p.out or "true") .. ")" end } AUTHOR.actions.saveGame = { help = "Keep the whole game -- room, positions, vars, inventory -- in a numbered slot.", params = { slot = "number" }, emit = function(p) return "authorSaveGame(" .. (p.slot or "1") .. ")" end } AUTHOR.actions.loadGame = { help = "Bring a slot back.", params = { slot = "number" }, emit = function(p) return "authorLoadGame(" .. (p.slot or "1") .. ")" 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(kind) local id = kind .. "#" .. 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 kind. overrides is an entities entry: id, x, y, z, and vars. local function make(kind, x, y, z, overrides) local def = AUTHOR_GAME.kinds[kind] local instance if def == nil then debugPrint("Author: no kind called '" .. tostring(kind) .. "'") return nil end overrides = overrides or {} instance = { id = overrides.id or nextId(kind), kind = kind, 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 ~= "kind") 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(kind, x, y, z, overrides) local instance = make(kind, 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. function authorPlaySound(file, volume) soundPlay(soundFor(file), 0, math.floor(math.max(0, math.min(100, volume or 100)) * SOUND_MAX_VOLUME / 100 + 0.5)) end function authorPlayMusic(file) if music then musicStop(music) end music = musicLoad(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 }) 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 kind. 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(kind, player) local x, y, z = authorPointerWorld(player) if x ~= nil then return authorSpawn(kind, 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, kind = instance.kind, 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 kind 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 == "kind" then if (event.self == nil) or (event.self.kind ~= want) then return false end elseif name == "a" then if (event.self == nil) or (event.self.kind ~= want) then return false end elseif name == "b" then if (event.other == nil) or (event.other.kind ~= 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 kind; on an event about nothing in -- particular (a key, a room, the disc) it fans the rule out over every instance of the kind. if rule.each and (event.self ~= nil) and (event.self.kind ~= 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.kind, 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 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 -- ===== 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 kinds 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.kinds = game.kinds 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 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() 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 = authorY(a), authorY(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 function authorSwitchDown(what) 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) AUTHOR_SWITCHES[what] = nil authorEvent("released", { switch = what }) end