4598 lines
142 KiB
Text
4598 lines
142 KiB
Text
--[[
|
|
*
|
|
* Singe 3
|
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 3
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
|
* 02110-1301, USA.
|
|
*
|
|
*
|
|
--]]
|
|
|
|
|
|
|
|
-- The runtime an authored game is compiled against (FORGE.md).
|
|
--
|
|
-- A game made with Forge is not interpreted: Forge/AuthorCompile.singe turns its description into
|
|
-- ordinary Lua, and this file is the library that Lua calls. Rules become real functions, so
|
|
-- nothing walks a table every frame, and the result can be opened in ZeroBrane and edited by hand
|
|
-- like any other game.
|
|
--
|
|
-- The nouns (FORGE.md section 3):
|
|
--
|
|
-- layers which of the engine's own layers the game uses. A genre is only a choice of layers.
|
|
-- 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
|