4969 lines
150 KiB
Text
4969 lines
150 KiB
Text
--[[
|
|
*
|
|
* Singe 3
|
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 3
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
|
* 02110-1301, USA.
|
|
*
|
|
*
|
|
--]]
|
|
|
|
|
|
|
|
|
|
-- Forge: the authoring tool (FORGE.md).
|
|
--
|
|
-- It is a Singe game. Nothing here is a preview: the canvas is the same overlay, at the same
|
|
-- coordinates, that the game will be played in, so what is placed is what is seen.
|
|
--
|
|
-- Chrome is an RmlUi document, which is what RmlUi is good at; the canvas beside it is drawn into
|
|
-- the overlay and picked with collidePointRect, which is what Singe has always done with a pointer
|
|
-- and a set of rectangles. They compose because the engine offers a button to the GUI first and
|
|
-- passes on what it did not use, while pointer motion is never consumed at all.
|
|
--
|
|
-- The panel shows one of five lists -- the room's entities, the types, the rules, the room's tracks
|
|
-- -- and TAB cycles them. Whatever is selected is edited the same way: ENTER walks its fields and
|
|
-- each is typed. The fields come from the manifest in Author.singe, so a new look, behaviour,
|
|
-- condition, action, or event is editable the moment it is declared.
|
|
--
|
|
-- The file is two things. Everything up to the last section is a library: a scene that drives
|
|
-- the editor sets FORGE_LIBRARY before loading it and supplies its own callbacks. The last section
|
|
-- is Forge as a game -- the file chooser, the engine callbacks, and the play button -- and runs only
|
|
-- when nothing has said otherwise.
|
|
|
|
dofile("Singe/Framework.singe")
|
|
dofile("Forge/AuthorCompile.singe")
|
|
|
|
local lfs = require("lfs")
|
|
|
|
|
|
FORGE = {
|
|
game = nil, -- The description being edited.
|
|
path = nil,
|
|
room = 1, -- Index into game.rooms.
|
|
mode = "entities", -- entities, types, rules, tracks, or dialogues.
|
|
selected = nil, -- Index into the room's entities.
|
|
typeName = nil, -- The selected type.
|
|
rule = 1, -- Selected rule,
|
|
part = 0, -- and the selected condition or action within it; 0 is the rule itself.
|
|
track = 1, -- Selected track in the room,
|
|
key = 0, -- and the selected key within it; 0 is the track itself.
|
|
cursor = 0, -- The timeline's position: a disc frame or a time.
|
|
dirty = false,
|
|
gui = nil,
|
|
document = nil,
|
|
held = nil, -- The entity being dragged, or { key = ... } for a track box.
|
|
grabX = 0,
|
|
grabY = 0,
|
|
panelX = 0, -- The chrome slides sideways so nothing is permanently under it.
|
|
panelDrag = nil, -- Offset from the panel's left edge while it is being dragged.
|
|
lastField = nil, -- The field ENTER edited last, so the next ENTER moves on from it.
|
|
editing = nil, -- { key, text, was, set }: see "Typing a value".
|
|
picker = nil, -- A list being chosen from: see "Pickers".
|
|
undo = {}, -- Copies of the description, newest last.
|
|
redo = {}, -- What undo took away, newest last; any other change empties it.
|
|
sprites = {}, -- Sprite looks loaded for drawing, by file name; false when missing.
|
|
chooser = { files = {}, at = 1 }, -- What the chooser lists, and which is lit.
|
|
stash = {}, -- Behaviours and layers typed away, by owner, so typing them back keeps their values.
|
|
sheets = {}, -- The same files cut into frames, by file and frame count, for turned sheets.
|
|
video = nil, -- The disc layer's file, loaded so the timeline can scrub it.
|
|
videoFile = nil,
|
|
scene = nil, -- The 3D preview: { nodes, camera, yaw, pitch, distance, stamp }.
|
|
dialogue = nil, -- The selected dialogue's name,
|
|
dnode = nil, -- the selected node's name within it,
|
|
dchoice = 0, -- and the selected choice (0 is the node itself).
|
|
dact = 0, -- The selected action of that choice (0 is the choice itself).
|
|
drawing = nil, -- A polygon being drawn on the canvas: { points }.
|
|
closing = false, -- ESC has been pressed once on an unsaved description.
|
|
message = "loaded"
|
|
}
|
|
|
|
local PANEL_W = 190 -- Matches Forge.rml; the canvas starts to the right of it.
|
|
local HANDLE = 6 -- Half the size of the square drawn at an entity's own position.
|
|
local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just outside its edge.
|
|
local TAB_H = 54
|
|
local TAB_Y = 10
|
|
local UNDO_MAX = 40 -- Steps kept. A description is small; forty copies are nothing.
|
|
local NEW_W = 40 -- The box a new type wears until it is given a look.
|
|
local NEW_H = 40
|
|
local STRIP_H = 34 -- The timeline along the bottom of the canvas.
|
|
local STRIP_PAD = 8
|
|
local MODES = { "entities", "types", "rules", "tracks", "dialogues" }
|
|
local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true }
|
|
local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks.
|
|
local FILE_NONE = "(none)" -- The file picker's first entry: no file.
|
|
local FRAME_STEP = 1
|
|
local FRAME_LEAP = 10
|
|
local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it.
|
|
local GIZMO_LEN = 1.5 -- World units each gizmo arm reaches from the selected entity.
|
|
local GIZMO_GRAB = 10 -- Pixels a press may be from an arm's tip to take it.
|
|
local TURN_REACH = 18 -- Pixels the 2D turn handle stands off the selected entity's box.
|
|
local THUMB = 24 -- The types panel's thumbnails fit a square this many pixels across.
|
|
local ORBIT_STEP = 10 -- Degrees the editor camera turns per key.
|
|
local PICK_ROWS = 16 -- Rows a picker shows at once: the scancodes are two hundred and more.
|
|
local DROP_ROWS = 6 -- Rows a field's dropdown shows, floating under its row.
|
|
local ZOOM_STEP = 1.15 -- How much closer or farther per key.
|
|
local sceneBegun = false -- The 3D scene is switched on once per run of the editor.
|
|
|
|
FORGE_DEFAULTS = { -- What a new parameter starts as, by the manifest's type: only what
|
|
entity = "self", -- must be there to mean anything. A number, a string, a file, a room,
|
|
scancode = "SPACE", -- or a flag left empty takes its own default when the game is built.
|
|
switch = "SWITCH_BUTTON1",
|
|
expression = '""',
|
|
lua = "-- your Lua here",
|
|
state = "idle"
|
|
}
|
|
|
|
|
|
local function element(id)
|
|
return rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id)
|
|
end
|
|
|
|
|
|
function forgeRoom()
|
|
return FORGE.game.rooms[FORGE.room]
|
|
end
|
|
|
|
|
|
-- A sprite look's image, loaded once and kept, so the canvas can show it. A file that is not
|
|
-- there answers nil rather than ending the editor the way spriteLoad would: the entity is drawn
|
|
-- as a box until the name is right.
|
|
-- Where a file the description names is, for the editor: beside the description first (a
|
|
-- released game carries its files beside itself), then as the engine sees it from the games
|
|
-- directory, which is how a description in the data directory names the kit (Forge/kit/hero.png).
|
|
function forgeFile(name)
|
|
local beside = (FORGE.path or ""):match("^(.*[/\\])")
|
|
|
|
if beside and not name:match("^[/\\]") and not name:match("^%a:") and (lfs.attributes(beside .. name, "mode") == "file") then
|
|
return beside .. name
|
|
end
|
|
|
|
return name
|
|
end
|
|
|
|
|
|
local function spriteFor(file)
|
|
if FORGE.sprites[file] == nil then
|
|
if (file ~= "") and (lfs.attributes(forgeFile(file), "mode") == "file") then
|
|
FORGE.sprites[file] = spriteLoad(forgeFile(file))
|
|
else
|
|
FORGE.sprites[file] = false
|
|
end
|
|
end
|
|
|
|
return FORGE.sprites[file] or nil
|
|
end
|
|
|
|
|
|
-- The same file cut into frames, for drawing one of them turned.
|
|
local function sheetFor(file, frames)
|
|
local key = file .. "#" .. frames
|
|
|
|
if FORGE.sheets[key] == nil then
|
|
FORGE.sheets[key] = spriteFor(file) and spriteLoadFrames(frames, forgeFile(file)) or false
|
|
end
|
|
|
|
return FORGE.sheets[key] or nil
|
|
end
|
|
|
|
|
|
-- The sorted keys of a table, so fields always read in the same order.
|
|
local function sortedKeys(t)
|
|
local keys = {}
|
|
|
|
for key in pairs(t or {}) do
|
|
keys[#keys + 1] = key
|
|
end
|
|
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
|
|
|
return keys
|
|
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
|
|
|
|
|
|
-- The behaviour of a type that it carries, if any.
|
|
local fillDefaults
|
|
|
|
local function behaviourOf(entityType, name)
|
|
for _, b in ipairs(entityType.behaviours or {}) do
|
|
if b.kind == name then
|
|
return b
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- A list of kinded entries -- a type's behaviours, the game's layers -- rebuilt from a typed
|
|
-- comma list. One already there keeps its values; one typed away and typed back gets them
|
|
-- back too, since the setter runs on every keystroke and "pl" has no platformer in it yet; a
|
|
-- new one starts with the manifest's defaults; a name the manifest lacks is refused. stash is
|
|
-- where the dropped ones wait, per owner.
|
|
local function listFrom(text, list, manifest, stash, what)
|
|
local made = {}
|
|
|
|
for _, entry in ipairs(list or {}) do
|
|
stash[entry.kind] = entry
|
|
end
|
|
for want in string.gmatch(tostring(text), "[^,%s]+") do
|
|
local entry = stash[want] or { kind = want }
|
|
|
|
if manifest[want] ~= nil then
|
|
fillDefaults(entry, manifest[want].params, true)
|
|
made[#made + 1] = entry
|
|
else
|
|
FORGE.message = "no " .. what .. " called " .. want
|
|
end
|
|
end
|
|
|
|
return made
|
|
end
|
|
|
|
|
|
-- The names in a kinded list, as one line.
|
|
local function listText(list)
|
|
local names = {}
|
|
|
|
for _, entry in ipairs(list or {}) do
|
|
names[#names + 1] = entry.kind
|
|
end
|
|
|
|
return table.concat(names, ", ")
|
|
end
|
|
|
|
|
|
-- A table parameter as text and back. "r=80, g=80, b=100" is a map; "100,-20; 300,-20" a list of
|
|
-- points (x, y, and z when given); "name=head, bone=b_Head_05, w=0.5; name=body, ..." a list
|
|
-- of records, one per semicolon; "look=look|examine, take=take|get" a map whose values are
|
|
-- lists; "the; a; an" a list of words. Enough for a light, a fog, a spawner's points, a
|
|
-- target's zones, a parser's words, and a hud's bind; the description can hold anything, and
|
|
-- what this cannot say it leaves alone.
|
|
local function tableText(t)
|
|
if type(t) ~= "table" then
|
|
return t
|
|
end
|
|
if t[1] ~= nil then
|
|
local items = {}
|
|
|
|
for _, item in ipairs(t) do
|
|
if type(item) ~= "table" then
|
|
items[#items + 1] = tostring(item)
|
|
elseif (item.x ~= nil) and (item.name == nil) then
|
|
items[#items + 1] = tostring(item.x) .. "," .. tostring(item.y or 0) .. ((item.z ~= nil) and ("," .. tostring(item.z)) or "")
|
|
elseif item[1] ~= nil then
|
|
items[#items + 1] = tostring(item[1]) .. "," .. tostring(item[2] or 0) .. ((item[3] ~= nil) and ("," .. tostring(item[3])) or "")
|
|
else
|
|
items[#items + 1] = tableText(item)
|
|
end
|
|
end
|
|
return table.concat(items, "; ")
|
|
end
|
|
local parts = {}
|
|
|
|
for _, key in ipairs(sortedKeys(t)) do
|
|
local value = t[key]
|
|
|
|
if type(value) == "table" then
|
|
local words = {}
|
|
|
|
for _, word in ipairs(value) do
|
|
words[#words + 1] = tostring(word)
|
|
end
|
|
value = table.concat(words, "|")
|
|
end
|
|
parts[#parts + 1] = key .. "=" .. tostring(value)
|
|
end
|
|
|
|
return table.concat(parts, ", ")
|
|
end
|
|
|
|
|
|
-- A typed value: a number, true, false, a list of words split on |, or the text itself.
|
|
local function typedWord(value)
|
|
value = value:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if value:find("|", 1, true) then
|
|
local words = {}
|
|
|
|
for word in string.gmatch(value, "[^|]+") do
|
|
words[#words + 1] = typedWord(word)
|
|
end
|
|
return words
|
|
end
|
|
|
|
return tonumber(value) or ((value == "true") and true) or ((value == "false") and false) or value
|
|
end
|
|
|
|
|
|
-- One "k=v, k=v" record as a map.
|
|
local function recordFrom(text)
|
|
local map = {}
|
|
|
|
for name, value in string.gmatch(text, "([%w_]+)%s*=%s*([^,]*)") do
|
|
map[name] = typedWord(value)
|
|
end
|
|
|
|
return map
|
|
end
|
|
|
|
|
|
local function tableFrom(text)
|
|
text = tostring(text)
|
|
-- One record with no semicolons is a map: a colour, a bind, a parser's words.
|
|
if text:find("=") and not text:find(";", 1, true) then
|
|
return recordFrom(text)
|
|
end
|
|
local items = {}
|
|
|
|
for item in string.gmatch(text, "[^;]+") do
|
|
if item:find("=") then
|
|
items[#items + 1] = recordFrom(item)
|
|
else
|
|
local numbers = {}
|
|
|
|
for number in string.gmatch(item, "-?[%d.]+") do
|
|
numbers[#numbers + 1] = tonumber(number)
|
|
end
|
|
if #numbers >= 2 then
|
|
items[#items + 1] = { x = numbers[1], y = numbers[2], z = numbers[3] }
|
|
elseif #numbers == 1 then
|
|
items[#items + 1] = numbers[1]
|
|
else
|
|
local word = item:match("^%s*(.-)%s*$")
|
|
|
|
if word ~= "" then
|
|
items[#items + 1] = word
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return items
|
|
end
|
|
|
|
|
|
-- Text on its way into the document, with RML's special characters made harmless. A rule's note
|
|
-- and a Lua action's code both come from the author and both can contain them.
|
|
local function escape(text)
|
|
local out = tostring(text or "")
|
|
|
|
out = string.gsub(out, "&", "&")
|
|
out = string.gsub(out, "<", "<")
|
|
out = string.gsub(out, ">", ">")
|
|
|
|
return out
|
|
end
|
|
|
|
|
|
-- A path RmlUi takes as it is: the vfs resolves a document's images against the document, so a
|
|
-- game file is named from the game's own directory, which is where the editor runs.
|
|
local function rooted(file)
|
|
if file:match("^[/\\]") or file:match("^%a:") then
|
|
return file
|
|
end
|
|
|
|
return lfs.currentdir() .. "/" .. forgeFile(file)
|
|
end
|
|
|
|
|
|
-- What a type looks like, small, for its row in the panel: the sprite itself (one frame of a
|
|
-- sheet), or a swatch of the box's colour.
|
|
local function thumbnail(look)
|
|
if look.kind == "sprite" then
|
|
local sprite = spriteFor(look.file or "")
|
|
|
|
if sprite ~= nil then
|
|
local w, h = spriteGetWidth(sprite) / math.max(1, look.frames or 1), spriteGetHeight(sprite)
|
|
local fit = THUMB / math.max(1, w, h)
|
|
local rect = ((look.frames or 1) > 1) and string.format(' rect="0 0 %d %d"', math.floor(w), h) or ""
|
|
|
|
return string.format('<img class="thumb" src="%s"%s style="width: %dpx; height: %dpx;"/>', escape(rooted(look.file)), rect, math.floor(w * fit + 0.5), math.floor(h * fit + 0.5))
|
|
end
|
|
elseif look.kind == "text" then
|
|
return '<span class="thumb">Aa</span>'
|
|
elseif (look.kind == "none") or (look.kind == "grid") then
|
|
return '<span class="thumb dim">..</span>'
|
|
end
|
|
|
|
return string.format('<span class="thumb swatch" style="background-color: #%02x%02x%02xff;"></span>', look.r or 180, look.g or 180, look.b or 190)
|
|
end
|
|
|
|
|
|
-- ===== Entities on the canvas =================================================================
|
|
|
|
-- What an entity looks like: its type's look, with anything the entry overrides.
|
|
function forgeLook(entry)
|
|
local entityType = FORGE.game.types[entry.type]
|
|
|
|
return (entityType and entityType.look) or { kind = "none" }
|
|
end
|
|
|
|
|
|
-- An entity's box in overlay coordinates. A look without a size still gets one, so that a text
|
|
-- entity can be picked up and moved like anything else; a sprite is as big as its image.
|
|
function forgeBounds(entry)
|
|
local look = forgeLook(entry)
|
|
local w = look.w or 40
|
|
local h = look.h or 20
|
|
|
|
if look.kind == "sprite" then
|
|
local sprite = spriteFor(look.file or "")
|
|
|
|
if sprite ~= nil then
|
|
w = spriteGetWidth(sprite) / math.max(1, look.frames or 1)
|
|
h = spriteGetHeight(sprite)
|
|
end
|
|
elseif look.kind == "text" then
|
|
w = math.max(60, #((entry.vars and entry.vars.text) or look.text or "") * 9)
|
|
end
|
|
|
|
w = w * (entry.scale or 1)
|
|
h = h * (entry.scale or 1)
|
|
-- A line of text hangs from its point, its top left, as the game draws it; a look that
|
|
-- stands on its point (anchor feet) is drawn above it.
|
|
if look.kind == "text" then
|
|
return entry.x or 0, entry.y or 0, w, h
|
|
end
|
|
if look.anchor == "feet" then
|
|
return (entry.x or 0) - w / 2, (entry.y or 0) - h, w, h
|
|
end
|
|
|
|
return (entry.x or 0) - w / 2, (entry.y or 0) - h / 2, w, h
|
|
end
|
|
|
|
|
|
function forgeSelect(index)
|
|
FORGE.selected = index
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- The topmost entity under a point, so overlapping things pick the one drawn last.
|
|
function forgePick(x, y)
|
|
local entities = forgeRoom().entities
|
|
|
|
for index = #entities, 1, -1 do
|
|
local bx, by, bw, bh = forgeBounds(entities[index])
|
|
|
|
if collidePointRect(x, y, bx, by, bw, bh) then
|
|
return index
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- ===== The panel ==============================================================================
|
|
|
|
-- The fields of whatever is selected, one row each, with the one ENTER is on marked. This is the
|
|
-- detail panel for an entity, a type, a rule, and a key alike: the same rows, the same keys.
|
|
-- Every row the panel lists is a div with an id, and a click on it goes to forgeRowClick with
|
|
-- that id: the ids made while a list is built are wired once the list is on screen, since
|
|
-- setting inner_rml makes new elements.
|
|
local pendingRows = {}
|
|
|
|
local function row(id, class, inner)
|
|
pendingRows[#pendingRows + 1] = id
|
|
|
|
return string.format('<div id="%s" class="%s">%s</div>', id, class, inner)
|
|
end
|
|
|
|
|
|
local function wireRows()
|
|
for _, id in ipairs(pendingRows) do
|
|
local rowId = id
|
|
|
|
guiSetHandler(FORGE.gui, FORGE.document, rowId, "click", function()
|
|
forgeRowClick(rowId)
|
|
end)
|
|
end
|
|
pendingRows = {}
|
|
end
|
|
|
|
|
|
-- A field's name as the panel shows it: an abbreviation in capitals, the rest as it is.
|
|
local function fieldLabel(name)
|
|
return (name:gsub("^id$", "ID"):gsub("%.id$", ".ID"))
|
|
end
|
|
|
|
|
|
-- The picker's rows: a window of PICK_ROWS around the one chosen, with what lies past it said,
|
|
-- and the line typed to narrow it. class is the row's style: a list taking the panel over
|
|
-- wears the panel's rows, a field's dropdown its own.
|
|
local function pickerRows(class, window)
|
|
local rows = {}
|
|
local shown = forgePickItems()
|
|
local from = math.max(1, math.min(FORGE.picker.at - math.floor(window / 2), #shown - window + 1))
|
|
local to = math.min(#shown, from + window - 1)
|
|
|
|
if from > 1 then
|
|
rows[#rows + 1] = '<div class="' .. class .. ' more">' .. (from - 1) .. " more above</div>"
|
|
end
|
|
for at = from, to do
|
|
rows[#rows + 1] = row("p" .. at, (at == FORGE.picker.at) and (class .. " selected") or class, escape(shown[at].name))
|
|
end
|
|
if to < #shown then
|
|
rows[#rows + 1] = '<div class="' .. class .. ' more">' .. (#shown - to) .. " more below</div>"
|
|
end
|
|
rows[#rows + 1] = '<div class="' .. class .. ' more">' .. (FORGE.picker.free and "type to narrow, or a new one: " or "type to narrow: ") .. escape(FORGE.picker.filter) .. "_</div>"
|
|
|
|
return table.concat(rows), shown
|
|
end
|
|
|
|
|
|
-- A field's list unfolds under its own row, as a dropdown does, with the help of the row chosen.
|
|
local function dropdown()
|
|
local rows, shown = pickerRows("choice", DROP_ROWS)
|
|
local help = (shown[FORGE.picker.at] or {}).help
|
|
|
|
-- Hidden until forgePlace has measured it against the panel: it opens upward when there is
|
|
-- no room below.
|
|
FORGE.placing = FORGE.frame or 0
|
|
|
|
return '<div id="drop" class="drop" style="visibility: hidden;">' .. rows .. (help and ('<div class="choice more">' .. escape(help) .. "</div>") or "") .. "</div>"
|
|
end
|
|
|
|
|
|
local function fieldRows(fields)
|
|
local rows = {}
|
|
|
|
for index, name in ipairs(fields.names) do
|
|
local class = (name == FORGE.lastField) and "part here" or "part"
|
|
local shown = tostring(fields.get(name))
|
|
|
|
-- The field being typed shows its text in its own row: selected whole, so typing
|
|
-- replaces it, or with the caret where the arrows put it. One with a list has it
|
|
-- unfolded under the row.
|
|
if FORGE.editing and (FORGE.editing.key == name) then
|
|
local editing = FORGE.editing
|
|
|
|
if FORGE.picker and FORGE.picker.field then
|
|
FORGE.dropRow = "f" .. index
|
|
rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. escape(shown)) .. dropdown()
|
|
else
|
|
if editing.selected then
|
|
shown = '<span class="picked">' .. escape(editing.text) .. "</span>"
|
|
else
|
|
shown = escape(editing.text:sub(1, editing.cursor)) .. "|" .. escape(editing.text:sub(editing.cursor + 1))
|
|
end
|
|
rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. shown)
|
|
end
|
|
else
|
|
rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. escape(shown))
|
|
end
|
|
end
|
|
|
|
return table.concat(rows)
|
|
end
|
|
|
|
|
|
local function detail(fields, head)
|
|
local rows = head or ""
|
|
|
|
if fields ~= nil then
|
|
rows = rows .. fieldRows(fields)
|
|
end
|
|
element("detail").inner_rml = rows .. '<div class="part">' .. escape(FORGE.message) .. "</div>"
|
|
wireRows()
|
|
end
|
|
|
|
|
|
local function refreshEntities()
|
|
local rows = {}
|
|
local room = forgeRoom()
|
|
|
|
for index, entry in ipairs(room.entities) do
|
|
local class = (index == FORGE.selected) and "row selected" or "row"
|
|
|
|
rows[#rows + 1] = row("e" .. index, class, escape((entry.id or "?") .. " (" .. tostring(entry.type) .. ")"))
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no entities here: A adds one</div>'
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
detail(forgeFields(), string.format('<div class="part">room %d of %d: %s</div>', FORGE.room, #FORGE.game.rooms, escape(room.name)))
|
|
end
|
|
|
|
|
|
local function refreshKinds()
|
|
local rows = {}
|
|
|
|
for index, name in ipairs(sortedKeys(FORGE.game.types)) do
|
|
local class = (name == FORGE.typeName) and "row selected" or "row"
|
|
|
|
rows[#rows + 1] = row("t" .. index, class, thumbnail(FORGE.game.types[name].look or {}) .. escape(name))
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no types yet: A adds one</div>'
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
detail(forgeFields())
|
|
end
|
|
|
|
|
|
-- The event sheet. The selected rule is opened in place and its conditions and actions listed
|
|
-- under it, because a rule only means anything as a whole: seeing "when" without "then" is no use.
|
|
local function refreshRules()
|
|
local rows = {}
|
|
|
|
for index, rule in ipairs(FORGE.game.rules) do
|
|
local class = (index == FORGE.rule) and "row selected" or "row"
|
|
|
|
rows[#rows + 1] = row("r" .. index, class, index .. ". " .. escape(rule.note or "rule"))
|
|
if index == FORGE.rule then
|
|
local parts = forgeParts(rule)
|
|
|
|
rows[#rows + 1] = row("r" .. index .. "p0", "part", "on " .. escape(rule.on or "frame") .. (rule.each and (" each " .. escape(rule.each)) or ""))
|
|
for at, part in ipairs(parts) do
|
|
local mark = (at == FORGE.part) and "part here" or "part"
|
|
|
|
rows[#rows + 1] = row("r" .. index .. "p" .. at, mark, ((part.kind == "when") and "when " or "then ") .. escape(forgePartText(part.item)))
|
|
end
|
|
if #parts == 0 then
|
|
rows[#rows + 1] = '<div class="part">(C adds a condition, T an action)</div>'
|
|
end
|
|
end
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no rules yet: N adds one</div>'
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
detail(forgeFields(), string.format('<div class="part">rule %d of %d</div>', FORGE.rule, #FORGE.game.rules))
|
|
end
|
|
|
|
|
|
local function refreshTracks()
|
|
local rows = {}
|
|
local tracks = forgeRoom().tracks or {}
|
|
|
|
for index, track in ipairs(tracks) do
|
|
local class = (index == FORGE.track) and "row selected" or "row"
|
|
|
|
rows[#rows + 1] = row("k" .. index, class, escape(track.name or "?") .. (track.spawns and " (waves by " or " (by ") .. escape(track.key or "frame") .. ")")
|
|
if index == FORGE.track then
|
|
for at, key in ipairs(forgeTrackKeys(track)) do
|
|
local mark = (at == FORGE.key) and "part here" or "part"
|
|
|
|
if track.spawns then
|
|
rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s x %s%s%s", tostring(key.at), tostring(key.count or 1), escape(tostring(key.type)), key.from and (" from " .. escape(tostring(key.from))) or "", key.every and (" every " .. tostring(key.every)) or ""))
|
|
elseif track.points then
|
|
rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s, %s, %s%s", tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.z), key.stop and (" stop " .. escape(key.stop)) or ""))
|
|
else
|
|
rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s,%s %sx%s", tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.w), tostring(key.h)))
|
|
end
|
|
end
|
|
if #forgeTrackKeys(track) == 0 then
|
|
rows[#rows + 1] = '<div class="part">(K adds a key at the cursor)</div>'
|
|
end
|
|
end
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no tracks here: N adds one</div>'
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
detail(forgeFields(), string.format('<div class="part">cursor at %s (, . step [ ] leap)</div>', tostring(FORGE.cursor)))
|
|
end
|
|
|
|
|
|
-- A picker takes the list over while it is up: the manifest is what is being chosen from, and
|
|
-- the panel is too narrow to show it beside anything else.
|
|
local function refreshPicker()
|
|
local rows, shown = pickerRows("row", PICK_ROWS)
|
|
|
|
element("list").inner_rml = rows
|
|
element("detail").inner_rml = escape(FORGE.picker.title) .. "<br/>" .. escape((shown[FORGE.picker.at] or {}).help or "") .. '<div class="part">UP / DOWN choose, ENTER or a click takes it, ESC leaves it</div>'
|
|
wireRows()
|
|
end
|
|
|
|
|
|
-- The dialogues, as an outline: every dialogue, the selected one's nodes under it, the selected
|
|
-- node's choices under that, and a choice's actions under that.
|
|
function forgeDialogueRows()
|
|
local rows = {}
|
|
|
|
for _, name in ipairs(sortedKeys(FORGE.game.dialogues)) do
|
|
local dialogue = FORGE.game.dialogues[name]
|
|
|
|
rows[#rows + 1] = { level = "dialogue", dialogue = name, label = name }
|
|
if name == FORGE.dialogue then
|
|
for _, nodeName in ipairs(sortedKeys(dialogue.nodes)) do
|
|
local node = dialogue.nodes[nodeName]
|
|
|
|
rows[#rows + 1] = { level = "node", dialogue = name, node = nodeName, label = nodeName .. ((nodeName == dialogue.start) and " (start)" or "") .. ": " .. (node.who and (node.who .. ": ") or "") .. tostring(node.text or "") }
|
|
if nodeName == FORGE.dnode then
|
|
for index, choice in ipairs(node.choices or {}) do
|
|
rows[#rows + 1] = { level = "choice", dialogue = name, node = nodeName, choice = index, label = index .. ". " .. tostring(choice.text or "") .. (choice.next and (" -> " .. choice.next) or "") }
|
|
if index == FORGE.dchoice then
|
|
for at, item in ipairs(choice.act or {}) do
|
|
rows[#rows + 1] = { level = "act", dialogue = name, node = nodeName, choice = index, act = at, label = "then " .. forgePartText(item) }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return rows
|
|
end
|
|
|
|
|
|
local dialogueSelect
|
|
|
|
local function dialogueRowSelected(row)
|
|
if row.level == "dialogue" then
|
|
return (row.dialogue == FORGE.dialogue) and (FORGE.dnode == nil)
|
|
elseif row.level == "node" then
|
|
return (row.node == FORGE.dnode) and (FORGE.dchoice == 0)
|
|
elseif row.level == "choice" then
|
|
return (row.node == FORGE.dnode) and (row.choice == FORGE.dchoice) and (FORGE.dact == 0)
|
|
end
|
|
|
|
return (row.node == FORGE.dnode) and (row.choice == FORGE.dchoice) and (row.act == FORGE.dact)
|
|
end
|
|
|
|
|
|
local function refreshDialogues()
|
|
local rows = {}
|
|
|
|
for index, item in ipairs(forgeDialogueRows()) do
|
|
local class = ({ dialogue = "row", node = "part", choice = "part", act = "part" })[item.level]
|
|
|
|
if dialogueRowSelected(item) then
|
|
class = (item.level == "dialogue") and "row selected" or "part here"
|
|
end
|
|
rows[#rows + 1] = row("d" .. index, class, escape(item.label))
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no dialogues yet: A adds one</div>'
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
detail(forgeFields())
|
|
end
|
|
|
|
|
|
-- Moves the dialogue selection to a row of the outline.
|
|
dialogueSelect = function(row)
|
|
FORGE.dialogue = row.dialogue
|
|
FORGE.dnode = row.node
|
|
FORGE.dchoice = row.choice or 0
|
|
FORGE.dact = row.act or 0
|
|
FORGE.lastField = nil
|
|
end
|
|
|
|
|
|
-- A step up or down the outline.
|
|
function forgeDialogueStep(by)
|
|
local rows = forgeDialogueRows()
|
|
local at = 0
|
|
|
|
for index, row in ipairs(rows) do
|
|
if dialogueRowSelected(row) then
|
|
at = index
|
|
end
|
|
end
|
|
at = math.max(1, math.min(#rows, at + by))
|
|
if rows[at] then
|
|
dialogueSelect(rows[at])
|
|
end
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- A name no dialogue, or no node of a dialogue, has.
|
|
local function uniqueName(set, stem)
|
|
local n = 1
|
|
|
|
if set[stem] == nil then
|
|
return stem
|
|
end
|
|
while set[stem .. n] ~= nil do
|
|
n = n + 1
|
|
end
|
|
|
|
return stem .. n
|
|
end
|
|
|
|
|
|
-- A adds at the level selected: a dialogue, a node in it, or a choice in the node.
|
|
function forgeDialogueAdd()
|
|
FORGE.game.dialogues = FORGE.game.dialogues or {}
|
|
forgeRemember()
|
|
if FORGE.dialogue == nil or FORGE.game.dialogues[FORGE.dialogue] == nil then
|
|
local name = uniqueName(FORGE.game.dialogues, "talk")
|
|
|
|
FORGE.game.dialogues[name] = { start = "start", nodes = { start = { who = "", text = "...", choices = {} } } }
|
|
FORGE.dialogue = name
|
|
FORGE.dnode = nil
|
|
FORGE.message = "added dialogue " .. name
|
|
elseif FORGE.dnode == nil then
|
|
local dialogue = FORGE.game.dialogues[FORGE.dialogue]
|
|
local name = uniqueName(dialogue.nodes, "node")
|
|
|
|
dialogue.nodes[name] = { who = "", text = "...", choices = {} }
|
|
FORGE.dnode = name
|
|
FORGE.dchoice = 0
|
|
FORGE.message = "added node " .. name
|
|
else
|
|
local node = FORGE.game.dialogues[FORGE.dialogue].nodes[FORGE.dnode]
|
|
|
|
node.choices = node.choices or {}
|
|
node.choices[#node.choices + 1] = { text = "..." }
|
|
FORGE.dchoice = #node.choices
|
|
FORGE.dact = 0
|
|
FORGE.message = "added a choice"
|
|
end
|
|
FORGE.dirty = true
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
function forgeDialogueDelete()
|
|
local dialogues = FORGE.game.dialogues or {}
|
|
local dialogue = dialogues[FORGE.dialogue or ""]
|
|
|
|
if dialogue == nil then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
if FORGE.dnode == nil then
|
|
dialogues[FORGE.dialogue] = nil
|
|
FORGE.dialogue = sortedKeys(dialogues)[1]
|
|
FORGE.message = "dialogue deleted"
|
|
elseif FORGE.dchoice == 0 then
|
|
dialogue.nodes[FORGE.dnode] = nil
|
|
FORGE.dnode = nil
|
|
FORGE.message = "node deleted"
|
|
elseif FORGE.dact == 0 then
|
|
table.remove(dialogue.nodes[FORGE.dnode].choices, FORGE.dchoice)
|
|
FORGE.dchoice = 0
|
|
FORGE.message = "choice deleted"
|
|
else
|
|
table.remove(dialogue.nodes[FORGE.dnode].choices[FORGE.dchoice].act, FORGE.dact)
|
|
FORGE.dact = 0
|
|
FORGE.message = "action deleted"
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- T adds an action to the selected choice, from the vocabulary.
|
|
function forgeDialogueAct(name)
|
|
local dialogue = FORGE.game.dialogues[FORGE.dialogue or ""]
|
|
local node = dialogue and dialogue.nodes[FORGE.dnode or ""]
|
|
local choice = node and node.choices and node.choices[FORGE.dchoice]
|
|
local item = forgeMakePart(AUTHOR.actions, name)
|
|
|
|
if (choice == nil) or (item == nil) then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
choice.act = choice.act or {}
|
|
choice.act[#choice.act + 1] = item
|
|
FORGE.dact = #choice.act
|
|
FORGE.lastField = nil
|
|
FORGE.dirty = true
|
|
FORGE.message = "added " .. name
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- The fields of the selected dialogue, node, choice, or action.
|
|
function forgeDialogueFields()
|
|
local dialogues = FORGE.game.dialogues or {}
|
|
local dialogue = dialogues[FORGE.dialogue or ""]
|
|
|
|
if dialogue == nil then
|
|
return nil
|
|
end
|
|
if FORGE.dnode == nil then
|
|
return {
|
|
names = { "name", "start" },
|
|
get = function(key) return (key == "name") and FORGE.dialogue or dialogue.start end,
|
|
set = function(key, value)
|
|
if (key == "name") and (value ~= "") and (dialogues[value] == nil) then
|
|
dialogues[value] = dialogue
|
|
dialogues[FORGE.dialogue] = nil
|
|
FORGE.dialogue = value
|
|
elseif key == "start" then
|
|
dialogue.start = value
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
local node = dialogue.nodes[FORGE.dnode]
|
|
|
|
if node == nil then
|
|
return nil
|
|
end
|
|
if FORGE.dchoice == 0 then
|
|
return {
|
|
names = { "name", "who", "text", "seconds", "next" },
|
|
get = function(key) return (key == "name") and FORGE.dnode or node[key] end,
|
|
set = function(key, value)
|
|
if key == "name" then
|
|
if (value ~= "") and (dialogue.nodes[value] == nil) then
|
|
dialogue.nodes[value] = node
|
|
dialogue.nodes[FORGE.dnode] = nil
|
|
if dialogue.start == FORGE.dnode then
|
|
dialogue.start = value
|
|
end
|
|
FORGE.dnode = value
|
|
end
|
|
elseif (key == "next" or key == "who") and (value == "") then
|
|
node[key] = nil
|
|
else
|
|
node[key] = value
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
local choice = node.choices and node.choices[FORGE.dchoice]
|
|
|
|
if choice == nil then
|
|
return nil
|
|
end
|
|
if FORGE.dact == 0 then
|
|
return {
|
|
names = { "text", "when", "next", "once" },
|
|
get = function(key) return (key == "once") and (choice.once == true) or choice[key] end,
|
|
set = function(key, value)
|
|
if key == "once" then
|
|
choice.once = (value == true) or nil
|
|
elseif (key == "when" or key == "next") and (value == "") then
|
|
choice[key] = nil
|
|
else
|
|
choice[key] = value
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
local item = choice.act and choice.act[FORGE.dact]
|
|
|
|
if item == nil then
|
|
return nil
|
|
end
|
|
|
|
return {
|
|
names = forgeFieldNames(item),
|
|
get = function(key) return item[key] end,
|
|
set = function(key, value)
|
|
item[key] = value
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
|
|
|
|
function forgeRefresh()
|
|
if FORGE.gui == nil then
|
|
return
|
|
end
|
|
FORGE.placing = FORGE.frame or 0 -- Placed on the first draw after this frame.
|
|
element("title").inner_rml = escape(FORGE.game.title or "Untitled") .. (FORGE.dirty and " *" or "")
|
|
element("grip").inner_rml = "≡ " .. FORGE.mode:sub(1, 1):upper() .. FORGE.mode:sub(2)
|
|
if (FORGE.picker ~= nil) and not FORGE.picker.field then
|
|
refreshPicker()
|
|
elseif FORGE.mode == "types" then
|
|
refreshKinds()
|
|
elseif FORGE.mode == "rules" then
|
|
refreshRules()
|
|
elseif FORGE.mode == "tracks" then
|
|
refreshTracks()
|
|
elseif FORGE.mode == "dialogues" then
|
|
refreshDialogues()
|
|
else
|
|
refreshEntities()
|
|
end
|
|
end
|
|
|
|
|
|
-- ===== Opening and closing ====================================================================
|
|
|
|
-- The disc layer's file, if the description has one, loaded and paused so the timeline can show
|
|
-- the frame under the cursor.
|
|
local function loadVideo()
|
|
local file
|
|
|
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
|
if (layer.kind == "disc") and layer.file then
|
|
file = layer.file
|
|
end
|
|
end
|
|
if file ~= FORGE.videoFile then
|
|
if FORGE.video then
|
|
videoUnload(FORGE.video)
|
|
FORGE.video = nil
|
|
end
|
|
FORGE.videoFile = file
|
|
if file and (lfs.attributes(forgeFile(file), "mode") == "file") then
|
|
FORGE.video = videoLoad(forgeFile(file))
|
|
videoPause(FORGE.video)
|
|
videoSeek(FORGE.video, FORGE.cursor)
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
function forgeBegin(path)
|
|
FORGE.path = path
|
|
FORGE.game = authorLoad(path)
|
|
if FORGE.game == nil then
|
|
return false
|
|
end
|
|
FORGE.game.types = FORGE.game.types or {}
|
|
FORGE.game.rules = FORGE.game.rules or {}
|
|
FORGE.game.layers = FORGE.game.layers or {}
|
|
FORGE.game.rooms = FORGE.game.rooms or {}
|
|
if #FORGE.game.rooms == 0 then
|
|
FORGE.game.rooms[1] = { name = "main", entities = {} }
|
|
end
|
|
for _, room in ipairs(FORGE.game.rooms) do
|
|
room.entities = room.entities or {}
|
|
room.tracks = room.tracks or {}
|
|
end
|
|
FORGE.room = 1
|
|
FORGE.mode = "entities"
|
|
FORGE.selected = nil
|
|
FORGE.typeName = sortedKeys(FORGE.game.types)[1]
|
|
FORGE.dialogue = sortedKeys(FORGE.game.dialogues or {})[1]
|
|
FORGE.dnode = nil
|
|
FORGE.dchoice = 0
|
|
FORGE.dact = 0
|
|
FORGE.drawing = nil
|
|
FORGE.rule = 1
|
|
FORGE.part = 0
|
|
FORGE.track = 1
|
|
FORGE.key = 0
|
|
FORGE.cursor = 0
|
|
FORGE.lastField = nil
|
|
FORGE.editing = nil
|
|
FORGE.picker = nil
|
|
FORGE.undo = {}
|
|
FORGE.redo = {}
|
|
FORGE.dirty = false
|
|
FORGE.closing = false
|
|
FORGE.message = "loaded"
|
|
FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight())
|
|
FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml")
|
|
guiSetInput(FORGE.gui, true)
|
|
guiSetHandler(FORGE.gui, FORGE.document, "grip", "mousedown", function()
|
|
local x = mouseGetPosition(0)
|
|
|
|
forgePanelGrab(x)
|
|
end)
|
|
guiSetHandler(FORGE.gui, FORGE.document, "grip", "mouseup", function()
|
|
FORGE.panelDrag = nil
|
|
forgeRefresh()
|
|
end)
|
|
-- The game's name at the top is the way back to the game's own form: nothing selected.
|
|
guiSetHandler(FORGE.gui, FORGE.document, "title", "click", function()
|
|
forgeMode("entities")
|
|
forgeSelect(nil)
|
|
end)
|
|
loadVideo()
|
|
forgeSceneBegin()
|
|
forgePanelTo(0)
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Puts the editor away. The description is dropped, saved or not: the caller decides that first.
|
|
function forgeClose()
|
|
if FORGE.gui ~= nil then
|
|
guiDelete(FORGE.gui)
|
|
end
|
|
for _, cache in ipairs({ FORGE.sprites, FORGE.sheets }) do
|
|
for _, sprite in pairs(cache) do
|
|
if sprite then
|
|
spriteUnload(sprite)
|
|
end
|
|
end
|
|
end
|
|
if FORGE.video then
|
|
videoUnload(FORGE.video)
|
|
end
|
|
if FORGE.scene then
|
|
forgeSceneClear()
|
|
nodeDelete(FORGE.scene.camera)
|
|
cameraSet(-1)
|
|
FORGE.scene = nil
|
|
end
|
|
FORGE.gui = nil
|
|
FORGE.document = nil
|
|
FORGE.game = nil
|
|
FORGE.path = nil
|
|
FORGE.sprites = {}
|
|
FORGE.sheets = {}
|
|
FORGE.video = nil
|
|
FORGE.videoFile = nil
|
|
FORGE.editing = nil
|
|
FORGE.picker = nil
|
|
FORGE.held = nil
|
|
FORGE.undo = {}
|
|
FORGE.redo = {}
|
|
end
|
|
|
|
|
|
-- Moves an entity. The description is the truth; nothing is cached anywhere else, so saving is
|
|
-- only writing it back out.
|
|
function forgeMove(index, x, y)
|
|
local entry = forgeRoom().entities[index]
|
|
|
|
entry.x = math.floor(x)
|
|
entry.y = math.floor(y)
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
function forgeSave()
|
|
authorSave(FORGE.game, FORGE.path)
|
|
FORGE.dirty = false
|
|
FORGE.closing = false
|
|
FORGE.message = "saved"
|
|
forgeRefresh()
|
|
|
|
return FORGE.path
|
|
end
|
|
|
|
|
|
-- The video a description plays over. Its disc layer's when it names one, the menu's otherwise.
|
|
function forgeVideo()
|
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
|
if (layer.kind == "disc") and layer.file then
|
|
return layer.file
|
|
end
|
|
end
|
|
|
|
return "Singe/menuBackground.mkv"
|
|
end
|
|
|
|
|
|
-- Everything a released game needs, in a directory of its own: the compiled script, the runtime
|
|
-- it loads, its games.dat, and the description it was built from so it can be opened again.
|
|
--
|
|
-- Named export rather than release because forgeRelease is the mouse button coming up; "release"
|
|
-- is a verb here and a noun there, and the two collided.
|
|
--
|
|
-- The runtime is copied out of Forge, which is the only place it exists: nothing of Forge ships
|
|
-- inside Singe, so a game carries the copy it needs and stands on its own from then on.
|
|
--
|
|
-- The result is a directory --pack turns into a .game, and it runs on a machine that has never had
|
|
-- Forge on it.
|
|
function forgeExport(folder, name)
|
|
local title = FORGE.game.title or "Untitled"
|
|
local script = name or string.gsub(title, "[^%w]", "")
|
|
local dat
|
|
|
|
if script == "" then
|
|
script = "Game"
|
|
end
|
|
-- lfs.mkdir answers false when the directory is already there, which is not a failure.
|
|
lfs.mkdir(folder)
|
|
if lfs.attributes(folder, "mode") ~= "directory" then
|
|
FORGE.message = "cannot make " .. folder
|
|
forgeRefresh()
|
|
return nil
|
|
end
|
|
|
|
FORGE.game.source = FORGE.path
|
|
local file = io.open(folder .. "/" .. script .. ".singe", "w")
|
|
|
|
if file == nil then
|
|
FORGE.message = "cannot write into " .. folder
|
|
forgeRefresh()
|
|
return nil
|
|
end
|
|
file:write(authorCompile(FORGE.game))
|
|
file:close()
|
|
|
|
if not authorCopy(AUTHOR_RUNTIME, folder .. "/Author.singe") then
|
|
FORGE.message = "could not copy the runtime"
|
|
forgeRefresh()
|
|
return nil
|
|
end
|
|
|
|
-- Every file the description names goes with it, at the same relative name, which is where
|
|
-- the runtime looks first; the description itself is unchanged by the trip.
|
|
for _, name in ipairs(forgeFilesNamed(FORGE.game)) do
|
|
if not forgeCopyInto(name, folder) then
|
|
FORGE.message = "could not copy " .. name
|
|
forgeRefresh()
|
|
return nil
|
|
end
|
|
end
|
|
|
|
-- The description travels with the game, so the release can be opened and edited again.
|
|
authorSave(FORGE.game, folder .. "/" .. script .. ".game")
|
|
|
|
-- A games.dat so the menu lists it, written from what the description already knows. Names
|
|
-- in it are as the engine sees them from the games directory, the folder's own name first,
|
|
-- which is how a packed copy addresses its files too. A game over a disc names its video in
|
|
-- the description, and the copy made above is what the entry names; anything else plays over
|
|
-- the menu's, since the engine wants one and the game draws over all of it anyway.
|
|
local base = folder:match("([^/\\]+)[/\\]*$") or folder
|
|
local video = forgeVideo()
|
|
|
|
if not video:match("^Singe[/\\]") and not video:match("^[/\\]") and not video:match("^%a:") then
|
|
video = base .. "/" .. video
|
|
end
|
|
dat = io.open(folder .. "/games.dat", "w")
|
|
if dat ~= nil then
|
|
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
|
|
title, base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
|
|
dat:close()
|
|
end
|
|
|
|
FORGE.message = "released to " .. folder
|
|
forgeRefresh()
|
|
|
|
return folder
|
|
end
|
|
|
|
|
|
-- Where X releases to: a folder in the data directory named for the game, which is also what
|
|
-- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the
|
|
-- folder and its name.
|
|
function forgeReleaseFolder()
|
|
local stem = string.gsub(FORGE.game.title or "Untitled", "[^%w]", "")
|
|
|
|
return singeGetDataPath() .. ((stem ~= "") and stem or "Game")
|
|
end
|
|
|
|
|
|
-- Every file a description names, by the manifest's word for what takes one: layers, looks,
|
|
-- behaviours, and actions with a "file" parameter, wherever they occur (rules, and the choices
|
|
-- of dialogues). The engine's own files (Singe/...) and absolute paths are not the game's to carry.
|
|
function forgeFilesNamed(game)
|
|
local seen = {}
|
|
local found = {}
|
|
|
|
local function take(name)
|
|
if (type(name) == "string") and (name ~= "") and not seen[name] and not name:match("^Singe[/\\]") and not name:match("^[/\\]") and not name:match("^%a:") then
|
|
seen[name] = true
|
|
found[#found + 1] = name
|
|
end
|
|
end
|
|
|
|
local function fromParams(entry, params)
|
|
for key, kind in pairs(params or {}) do
|
|
if kind == "file" then
|
|
take(entry[key])
|
|
end
|
|
end
|
|
end
|
|
|
|
local function fromActs(acts)
|
|
for _, act in ipairs(acts or {}) do
|
|
fromParams(act, (AUTHOR.actions[act[1]] or {}).params)
|
|
end
|
|
end
|
|
|
|
for _, layer in ipairs(game.layers or {}) do
|
|
fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params)
|
|
end
|
|
for _, entityType in pairs(game.types or {}) do
|
|
if entityType.look then
|
|
fromParams(entityType.look, (AUTHOR.looks[entityType.look.kind] or {}).params)
|
|
end
|
|
for _, behaviour in ipairs(entityType.behaviours or {}) do
|
|
fromParams(behaviour, (AUTHOR.behaviours[behaviour.kind] or {}).params)
|
|
end
|
|
end
|
|
for _, rule in ipairs(game.rules or {}) do
|
|
fromActs(rule.act)
|
|
end
|
|
for _, dialogue in pairs(game.dialogues or {}) do
|
|
for _, node in ipairs(dialogue.nodes or dialogue) do
|
|
if type(node) == "table" then
|
|
fromActs(node.act)
|
|
for _, choice in ipairs(node.choices or {}) do
|
|
fromActs(choice.act)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(found)
|
|
|
|
return found
|
|
end
|
|
|
|
|
|
-- Copies a named file into a folder at the same relative name, making the directories on the way.
|
|
function forgeCopyInto(name, folder)
|
|
local at = folder
|
|
|
|
for part in name:gmatch("([^/\\]+)[/\\]") do
|
|
at = at .. "/" .. part
|
|
lfs.mkdir(at)
|
|
end
|
|
|
|
return authorCopy(forgeFile(name), folder .. "/" .. name)
|
|
end
|
|
|
|
|
|
-- Compiles what is on screen and hands back the path, so the caller can dofile it and play. The
|
|
-- runtime goes beside it, because that is where the compiled game looks for it. What the checker
|
|
-- has to say goes to the console and the panel.
|
|
function forgeBuild(outputFile, fromRoom)
|
|
local problems = authorCheck(FORGE.game)
|
|
local game = FORGE.game
|
|
|
|
FORGE.game.source = FORGE.path
|
|
-- Playing from here: a copy with the current room first, so the game begins in it.
|
|
if fromRoom and (fromRoom > 1) and FORGE.game.rooms[fromRoom] then
|
|
game = {}
|
|
for key, value in pairs(FORGE.game) do
|
|
game[key] = value
|
|
end
|
|
game.rooms = {}
|
|
for index = 0, #FORGE.game.rooms - 1 do
|
|
game.rooms[index + 1] = FORGE.game.rooms[((fromRoom - 1 + index) % #FORGE.game.rooms) + 1]
|
|
end
|
|
end
|
|
local file = assert(io.open(outputFile, "w"))
|
|
|
|
file:write(authorCompile(game))
|
|
file:close()
|
|
authorCopy(AUTHOR_RUNTIME, (string.match(outputFile, "^(.*[/\\])") or "") .. "Author.singe")
|
|
for _, problem in ipairs(problems) do
|
|
debugPrint("Forge: " .. problem)
|
|
end
|
|
FORGE.message = (#problems == 0) and "built" or ("built with " .. #problems .. " problems: " .. problems[1])
|
|
forgeRefresh()
|
|
|
|
return outputFile
|
|
end
|
|
|
|
|
|
-- ===== The pointer on the canvas ==============================================================
|
|
|
|
function forgePress(x, y)
|
|
-- Whatever is under the chrome belongs to it, wherever it has been dragged to. The GUI has
|
|
-- already had its chance at this click; checking again is belt and braces for the case where
|
|
-- an element did not claim it.
|
|
-- The tab first: it sits outside the panel, so it is reachable, and it is what moves the panel.
|
|
local tx, ty, tw, th = forgeTab()
|
|
|
|
if not FORGE.panelHidden and collidePointRect(x, y, tx, ty, tw, th) then
|
|
forgePanelGrab(x)
|
|
return
|
|
end
|
|
if forgeOverPanel(x) then
|
|
return
|
|
end
|
|
-- A polygon being drawn takes every press as a corner.
|
|
if FORGE.drawing then
|
|
FORGE.drawing.points[#FORGE.drawing.points + 1] = math.floor(x)
|
|
FORGE.drawing.points[#FORGE.drawing.points + 1] = math.floor(y)
|
|
FORGE.message = (#FORGE.drawing.points / 2) .. " corners; V or ENTER closes it, ESC drops it"
|
|
forgeRefresh()
|
|
return
|
|
end
|
|
-- In the tracks, a press on the selected track's box picks the box up: the key at the cursor,
|
|
-- made if there is none.
|
|
if FORGE.mode == "tracks" then
|
|
local box = forgeCursorBox()
|
|
|
|
if box and collidePointRect(x, y, box.x, box.y, box.w, box.h) then
|
|
forgeRemember()
|
|
FORGE.held = { key = forgeKeyAtCursor(true) }
|
|
FORGE.grabX = x - box.x
|
|
FORGE.grabY = y - box.y
|
|
forgeRefresh()
|
|
end
|
|
return
|
|
end
|
|
-- A gizmo handle first: it moves or turns the selected entity, whatever lies under it.
|
|
local handle = forgeGizmoAt(x, y)
|
|
|
|
if handle then
|
|
forgeGizmoHold(handle, x, y)
|
|
return
|
|
end
|
|
if FORGE.scene then
|
|
FORGE.held = forgePick3D(x, y)
|
|
if FORGE.held ~= nil then
|
|
local entry = forgeRoom().entities[FORGE.held]
|
|
local gx, gz = forgeGroundPoint(x, y, entry.y or 0)
|
|
|
|
forgeRemember()
|
|
FORGE.grabX = gx and (gx - (entry.x or 0)) or 0
|
|
FORGE.grabY = gz and (gz - (entry.z or 0)) or 0
|
|
FORGE.mode = "entities"
|
|
forgeSelect(FORGE.held)
|
|
end
|
|
return
|
|
end
|
|
FORGE.held = forgePick(x, y)
|
|
if FORGE.held ~= nil then
|
|
local entry = forgeRoom().entities[FORGE.held]
|
|
|
|
-- One undo step for the whole drag, taken before it starts.
|
|
forgeRemember()
|
|
FORGE.grabX = x - entry.x
|
|
FORGE.grabY = y - entry.y
|
|
FORGE.mode = "entities"
|
|
forgeSelect(FORGE.held)
|
|
elseif FORGE.mode == "entities" then
|
|
-- A press on nothing selects nothing, which is the game's own form.
|
|
forgeSelect(nil)
|
|
end
|
|
end
|
|
|
|
|
|
function forgeRelease()
|
|
FORGE.held = nil
|
|
FORGE.panelDrag = nil
|
|
end
|
|
|
|
|
|
-- A click on a row of the panel, by the row's id: e3 an entity, t2 a type, r1 a rule and r1p2
|
|
-- its second part, k1 a track and k1y2 its second key, d4 the fourth row of the dialogue outline,
|
|
-- p3 the third thing a picker offers, f2 the second field of the form. A row selects what it
|
|
-- names, as the arrows would; a field starts typing it, as ENTER would after walking to it.
|
|
function forgeRowClick(id)
|
|
local letter, first, second = id:match("^(%a)(%d+)%a?(%d*)$")
|
|
local room = forgeRoom()
|
|
|
|
first = tonumber(first)
|
|
second = tonumber(second)
|
|
if letter == "e" then
|
|
FORGE.mode = "entities"
|
|
forgeSelect(first)
|
|
elseif letter == "t" then
|
|
FORGE.typeName = sortedKeys(FORGE.game.types)[first] or FORGE.typeName
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif letter == "r" then
|
|
if FORGE.game.rules[first] ~= nil then
|
|
FORGE.rule = first
|
|
FORGE.part = math.min(second or 0, #forgeParts(FORGE.game.rules[first]))
|
|
end
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif letter == "k" then
|
|
if room.tracks[first] ~= nil then
|
|
local keys = forgeTrackKeys(room.tracks[first])
|
|
|
|
FORGE.track = first
|
|
FORGE.key = ((second or 0) <= #keys) and (second or 0) or 0
|
|
if FORGE.key > 0 then
|
|
forgeCursorTo(keys[FORGE.key].at)
|
|
end
|
|
end
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif letter == "d" then
|
|
local item = forgeDialogueRows()[first]
|
|
|
|
if item then
|
|
dialogueSelect(item)
|
|
end
|
|
forgeRefresh()
|
|
elseif letter == "p" then
|
|
local picker = FORGE.picker
|
|
local shown = picker and forgePickItems() or {}
|
|
|
|
if shown[first] then
|
|
FORGE.picker = nil
|
|
picker.choose(shown[first].name)
|
|
end
|
|
elseif letter == "f" then
|
|
forgeEditField(first)
|
|
end
|
|
end
|
|
|
|
|
|
-- Starts typing one field of the form, by its place in it: the field before it is made the last
|
|
-- one walked, and the walk goes on from there.
|
|
function forgeEditField(index)
|
|
local fields = forgeFields()
|
|
|
|
if (fields == nil) or (fields.names[index] == nil) then
|
|
return false
|
|
end
|
|
if FORGE.editing ~= nil then
|
|
forgeEditCommit()
|
|
end
|
|
FORGE.lastField = (index > 1) and fields.names[index - 1] or nil
|
|
|
|
return forgeEditNext()
|
|
end
|
|
|
|
|
|
-- Whether an x lies under the chrome as it is placed right now.
|
|
function forgeOverPanel(x)
|
|
return not FORGE.panelHidden and (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W)
|
|
end
|
|
|
|
|
|
-- Hides the chrome, or shows it again: a polygon is drawn with the whole canvas bare.
|
|
function forgePanelShow(on)
|
|
FORGE.panelHidden = not on
|
|
if FORGE.gui ~= nil then
|
|
element("panel").style.display = on and "block" or "none"
|
|
end
|
|
end
|
|
|
|
|
|
-- Moves the chrome. Clamped so a panel dragged off the edge can always be got back.
|
|
function forgePanelTo(x)
|
|
local limit = overlayGetWidth() - PANEL_W
|
|
|
|
FORGE.panelX = math.max(0, math.min(math.floor(x), limit))
|
|
if FORGE.gui ~= nil then
|
|
element("panel").style.left = FORGE.panelX .. "px"
|
|
end
|
|
end
|
|
|
|
|
|
-- The tab that drags the panel, in overlay coordinates. It is deliberately drawn on the *canvas*,
|
|
-- just outside the panel, rather than being an element in the document: a press on the canvas is
|
|
-- the one pointer path this editor has been shown to receive reliably, whereas whether a document
|
|
-- gets the press at all depends on the GUI routing in _guiPointer, which the spike in FORGE.md
|
|
-- section 1 found silent under a window manager. Drawing the tab ourselves needs none of that.
|
|
function forgeTab()
|
|
return FORGE.panelX + PANEL_W, TAB_Y, TAB_W, TAB_H
|
|
end
|
|
|
|
|
|
function forgePanelGrab(x)
|
|
FORGE.panelDrag = x - FORGE.panelX
|
|
end
|
|
|
|
|
|
function forgeDrag(x, y)
|
|
if FORGE.panelDrag ~= nil then
|
|
forgePanelTo(x - FORGE.panelDrag)
|
|
elseif (type(FORGE.held) == "table") and FORGE.held.gizmo then
|
|
-- How far along the arm the pointer has gone, as a fraction of the arm, is how far along
|
|
-- the axis the entity goes.
|
|
local held = FORGE.held
|
|
local reach = held.dx * held.dx + held.dy * held.dy
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
|
|
if (held.gizmo == "ry") or (held.gizmo == "rz") then
|
|
-- The pointer's angle round the entity on screen, from where the press was, turns it:
|
|
-- with the screen's y downward, clockwise on the canvas is a positive rz, while ry
|
|
-- turns the other way seen from above.
|
|
local angle = math.atan(y - held.sy, x - held.sx)
|
|
local turn = math.deg(angle - held.angle)
|
|
|
|
entry[held.gizmo] = math.floor(held.from + ((held.gizmo == "rz") and turn or -turn) + 0.5) % 360
|
|
if entry[held.gizmo] == 0 then
|
|
entry[held.gizmo] = nil
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
elseif (reach > 0) and entry then
|
|
local t = ((x - held.x) * held.dx + (y - held.y) * held.dy) / reach
|
|
|
|
entry[held.gizmo] = math.floor((held.from + t * GIZMO_LEN) * 10 + 0.5) / 10
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
elseif type(FORGE.held) == "table" then
|
|
FORGE.held.key.x = math.floor(x - FORGE.grabX)
|
|
FORGE.held.key.y = math.floor(y - FORGE.grabY)
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
elseif (FORGE.held ~= nil) and FORGE.scene then
|
|
local entry = forgeRoom().entities[FORGE.held]
|
|
local gx, gz = forgeGroundPoint(x, y, entry.y or 0)
|
|
|
|
if gx then
|
|
forgeMove3D(FORGE.held, gx - FORGE.grabX, entry.y or 0, gz - FORGE.grabY)
|
|
end
|
|
elseif FORGE.held ~= nil then
|
|
forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY)
|
|
end
|
|
end
|
|
|
|
|
|
-- Moves an entity in the scene, to a tenth of a unit.
|
|
function forgeMove3D(index, x, y, z)
|
|
local entry = forgeRoom().entities[index]
|
|
|
|
entry.x = math.floor(x * 10 + 0.5) / 10
|
|
entry.y = math.floor(y * 10 + 0.5) / 10
|
|
entry.z = math.floor(z * 10 + 0.5) / 10
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- ===== The 3D preview =========================================================================
|
|
--
|
|
-- A room in a game with a scene3d layer is shown as the scene itself: the room's entities are
|
|
-- built from their types' looks, exactly as the runtime builds them, under an editor camera that
|
|
-- orbits whatever is selected. Nothing steps -- no behaviours, no physics -- so it is the
|
|
-- description that is on screen, not a running game.
|
|
|
|
function forgeIs3D()
|
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
|
if layer.kind == "scene3d" then
|
|
return layer
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- The entities as a string, so the preview is rebuilt only when what they are changes; where
|
|
-- they are is synced every frame instead.
|
|
local function sceneStamp()
|
|
local parts = {}
|
|
|
|
for _, entry in ipairs(forgeRoom().entities) do
|
|
local entityType = FORGE.game.types[entry.type] or {}
|
|
local look = entityType.look or {}
|
|
|
|
parts[#parts + 1] = tostring(entry.type) .. ":" .. tostring(look.kind) .. ":" .. tostring(look.file) .. ":" .. tostring(look.shape) .. ":" .. tostring(look.w) .. ":" .. tostring(look.h) .. ":" .. tostring(look.d) .. ":" .. tostring(look.scale) .. ":" .. tostring(look.r) .. tostring(look.g) .. tostring(look.b)
|
|
end
|
|
|
|
return table.concat(parts, "|") .. "#" .. FORGE.room
|
|
end
|
|
|
|
|
|
function forgeSceneClear()
|
|
if FORGE.scene == nil then
|
|
return
|
|
end
|
|
for _, node in pairs(FORGE.scene.nodes) do
|
|
nodeDelete(node)
|
|
end
|
|
FORGE.scene.nodes = {}
|
|
FORGE.scene.stamp = nil
|
|
end
|
|
|
|
|
|
function forgeSceneBegin()
|
|
local layer = forgeIs3D()
|
|
|
|
if layer == nil then
|
|
return
|
|
end
|
|
if not sceneBegun then
|
|
AUTHOR.layers.scene3d.begin(layer)
|
|
sceneBegun = true
|
|
end
|
|
FORGE.scene = { nodes = {}, camera = nodeNew(), yaw = 30, pitch = 25, distance = 12, stamp = nil }
|
|
cameraSet(FORGE.scene.camera)
|
|
forgeSceneSync()
|
|
end
|
|
|
|
|
|
-- Builds what is missing and moves what is there. Called every frame the preview is drawn.
|
|
function forgeSceneSync()
|
|
local scene = FORGE.scene
|
|
local room = forgeRoom()
|
|
|
|
if scene == nil then
|
|
return
|
|
end
|
|
if scene.stamp ~= sceneStamp() then
|
|
forgeSceneClear()
|
|
for index, entry in ipairs(room.entities) do
|
|
local entityType = FORGE.game.types[entry.type] or {}
|
|
local look = entityType.look or { kind = "none" }
|
|
local instance = { node = nodeNew(), look = look, vars = { text = (entry.vars and entry.vars.text) or look.text }, nodes = {}, preview = true }
|
|
local maker = AUTHOR.looks[look.kind]
|
|
|
|
if maker and maker.load and (look.kind ~= "sprite") and (look.kind ~= "text") then
|
|
local ok, err = pcall(maker.load, instance)
|
|
|
|
if not ok then
|
|
debugPrint("Forge: " .. tostring(entry.id) .. " could not be shown: " .. tostring(err))
|
|
end
|
|
end
|
|
scene.nodes[index] = instance.node
|
|
end
|
|
scene.stamp = sceneStamp()
|
|
end
|
|
for index, entry in ipairs(room.entities) do
|
|
if scene.nodes[index] then
|
|
nodeSetPosition(scene.nodes[index], entry.x or 0, entry.y or 0, entry.z or 0)
|
|
nodeSetRotation(scene.nodes[index], entry.rx or 0, entry.ry or 0, entry.rz or 0)
|
|
nodeSetScale(scene.nodes[index], entry.scale or 1)
|
|
end
|
|
end
|
|
forgeSceneCamera()
|
|
end
|
|
|
|
|
|
-- What the editor camera looks at: the selected entity, or the room's middle.
|
|
local function sceneFocus()
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
|
|
if entry then
|
|
return entry.x or 0, entry.y or 0, entry.z or 0
|
|
end
|
|
|
|
return 0, 0, 0
|
|
end
|
|
|
|
|
|
function forgeSceneCamera()
|
|
local scene = FORGE.scene
|
|
local fx, fy, fz = sceneFocus()
|
|
local yaw = math.rad(scene.yaw)
|
|
local pitch = math.rad(scene.pitch)
|
|
|
|
nodeSetPosition(scene.camera, fx + math.sin(yaw) * math.cos(pitch) * scene.distance, fy + math.sin(pitch) * scene.distance, fz + math.cos(yaw) * math.cos(pitch) * scene.distance)
|
|
nodeLookAt(scene.camera, fx, fy, fz)
|
|
end
|
|
|
|
|
|
function forgeOrbit(yawBy, pitchBy, zoomBy)
|
|
local scene = FORGE.scene
|
|
|
|
scene.yaw = scene.yaw + yawBy
|
|
scene.pitch = math.max(-85, math.min(85, scene.pitch + pitchBy))
|
|
scene.distance = math.max(0.5, scene.distance * zoomBy)
|
|
forgeSceneCamera()
|
|
end
|
|
|
|
|
|
-- The gizmo: three arms from the selected entity along the world axes, each ending in a handle
|
|
-- that drags the entity along that axis alone. Arms are found by where their ends project.
|
|
function forgeGizmoHandles()
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
|
|
if entry == nil then
|
|
return {}
|
|
end
|
|
local x, y, z = entry.x or 0, entry.y or 0, entry.z or 0
|
|
local sx, sy, _, inFront = sceneProject(x, y, z)
|
|
local handles = {}
|
|
|
|
if not inFront then
|
|
return {}
|
|
end
|
|
for _, axis in ipairs({ { name = "x", dx = 1, dy = 0, dz = 0, r = 255, g = 90, b = 90 }, { name = "y", dx = 0, dy = 1, dz = 0, r = 90, g = 255, b = 90 }, { name = "z", dx = 0, dy = 0, dz = 1, r = 90, g = 140, b = 255 } }) do
|
|
local tx, ty, _, tipFront = sceneProject(x + axis.dx * GIZMO_LEN, y + axis.dy * GIZMO_LEN, z + axis.dz * GIZMO_LEN)
|
|
|
|
if tipFront then
|
|
handles[#handles + 1] = { axis = axis.name, sx = sx, sy = sy, tx = tx, ty = ty, r = axis.r, g = axis.g, b = axis.b }
|
|
end
|
|
end
|
|
-- The turn handle: a fourth arm the way the entity faces (its -Z, turned by ry), dragged
|
|
-- round the entity to turn it about Y.
|
|
local yaw = math.rad(entry.ry or 0)
|
|
local fx, fz = -math.sin(yaw) * GIZMO_LEN * 0.7, -math.cos(yaw) * GIZMO_LEN * 0.7
|
|
local tx, ty, _, tipFront = sceneProject(x + fx, y, z + fz)
|
|
|
|
if tipFront then
|
|
handles[#handles + 1] = { axis = "ry", sx = sx, sy = sy, tx = tx, ty = ty, r = 255, g = 200, b = 60 }
|
|
end
|
|
|
|
return handles
|
|
end
|
|
|
|
|
|
-- The gizmo handle under a screen point, if any: the scene's arms, or the 2D turn handle.
|
|
function forgeGizmoAt(x, y)
|
|
local handles = FORGE.scene and forgeGizmoHandles() or { forgeTurnHandle() }
|
|
|
|
for _, handle in ipairs(handles) do
|
|
if math.sqrt((handle.tx - x) ^ 2 + (handle.ty - y) ^ 2) <= GIZMO_GRAB then
|
|
return handle
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- Takes a gizmo handle: where the press was, along the arm and round the entity, is what a drag
|
|
-- is measured from.
|
|
function forgeGizmoHold(handle, x, y)
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
|
|
forgeRemember()
|
|
FORGE.held = { gizmo = handle.axis, from = entry[handle.axis] or 0, x = x, y = y, dx = handle.tx - handle.sx, dy = handle.ty - handle.sy, sx = handle.sx, sy = handle.sy, angle = math.atan(y - handle.sy, x - handle.sx) }
|
|
FORGE.message = ((handle.axis == "ry") or (handle.axis == "rz")) and "turning" or ("moving along " .. handle.axis)
|
|
end
|
|
|
|
|
|
-- An arm from the entity to its handle, the handle a small box named for its axis.
|
|
function forgeDrawHandle(handle)
|
|
if handle == nil then
|
|
return
|
|
end
|
|
colorForeground(handle.r, handle.g, handle.b, 255)
|
|
overlayLine(handle.sx, handle.sy, handle.tx, handle.ty)
|
|
overlayBox(handle.tx - 4, handle.ty - 4, handle.tx + 4, handle.ty + 4)
|
|
fontPrint(handle.tx + 6, handle.ty - 8, handle.axis)
|
|
end
|
|
|
|
|
|
-- The turn handle on the 2D canvas: an arm from the selected entity the way its rz points, past
|
|
-- its box, dragged round the entity to turn it. The counterpart of the scene's ry arm.
|
|
function forgeTurnHandle()
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
|
|
if (entry == nil) or FORGE.scene then
|
|
return nil
|
|
end
|
|
local _, _, w, h = forgeBounds(entry)
|
|
local reach = math.max(w, h) / 2 + TURN_REACH
|
|
local turn = math.rad(entry.rz or 0)
|
|
|
|
return { axis = "rz", sx = entry.x, sy = entry.y, tx = entry.x + math.cos(turn) * reach, ty = entry.y + math.sin(turn) * reach, r = 255, g = 200, b = 60 }
|
|
end
|
|
|
|
|
|
-- The entity nearest a screen point, within reach, by where it projects.
|
|
function forgePick3D(x, y)
|
|
local best, bestFar = nil, PICK_REACH
|
|
|
|
for index, entry in ipairs(forgeRoom().entities) do
|
|
local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0)
|
|
|
|
if inFront then
|
|
local far = math.sqrt((sx - x) ^ 2 + (sy - y) ^ 2)
|
|
|
|
if far < bestFar then
|
|
best, bestFar = index, far
|
|
end
|
|
end
|
|
end
|
|
|
|
return best
|
|
end
|
|
|
|
|
|
-- Where the pointer's ray crosses the horizontal plane through a height, so a drag moves an
|
|
-- entity across the floor it stands on rather than toward the camera.
|
|
function forgeGroundPoint(x, y, height)
|
|
local x0, y0, z0 = sceneUnproject(x, y, 0)
|
|
local x1, y1, z1 = sceneUnproject(x, y, 10)
|
|
local dy = y1 - y0
|
|
|
|
if math.abs(dy) < 0.0001 then
|
|
return nil
|
|
end
|
|
local t = (height - y0) / dy
|
|
|
|
if t < 0 then
|
|
return nil
|
|
end
|
|
|
|
return x0 + (x1 - x0) * t, z0 + (z1 - z0) * t
|
|
end
|
|
|
|
|
|
-- ===== Drawing ================================================================================
|
|
|
|
local function fill(x, y, w, h)
|
|
for row = math.floor(y), math.floor(y + h) do
|
|
overlayLine(x, row, x + w, row)
|
|
end
|
|
end
|
|
|
|
|
|
-- Draws the description. Note that this draws the *description*, not a running game: there are
|
|
-- no physics here, which is why an entity can be dragged through a wall.
|
|
-- The first element under one with a class, searched depth first, or nil.
|
|
local function findClass(under, class)
|
|
for index = 1, #under.child_nodes do
|
|
local child = under.child_nodes[index]
|
|
|
|
if child:IsClassSet(class) then
|
|
return child
|
|
end
|
|
local found = findClass(child, class)
|
|
|
|
if found then
|
|
return found
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- Where an element's top is within a box it is inside, in the box's content coordinates: a
|
|
-- scrolling box is an offset parent, so offsets are summed up the chain until the box.
|
|
local function offsetWithin(what, box)
|
|
local top = what.offset_top
|
|
local parent = what.offset_parent
|
|
|
|
while parent and (parent.id ~= box.id) do
|
|
top = top + parent.offset_top
|
|
parent = parent.offset_parent
|
|
end
|
|
|
|
return top
|
|
end
|
|
|
|
|
|
-- Scrolls a row into its box's view, moving the box as little as possible.
|
|
local function scrollTo(box, row)
|
|
local top = offsetWithin(row, box)
|
|
local bottom = top + row.offset_height
|
|
|
|
if top < box.scroll_top then
|
|
box.scroll_top = top
|
|
elseif bottom > box.scroll_top + box.client_height then
|
|
box.scroll_top = bottom - box.client_height
|
|
end
|
|
end
|
|
|
|
|
|
-- What can only be done once the panel is laid out, which the engine does at the end of the
|
|
-- frame the refresh happened in: the chosen row of the list and the field being edited are
|
|
-- scrolled into view, and a dropdown that would run off the bottom of the panel opens upward
|
|
-- instead. Run at the start of every draw, and done on the first draw after the frame the
|
|
-- refresh happened in: a key or a click is handled before that frame's draw, and the elements
|
|
-- it made measure nothing until the engine has laid them out. A refresh asks for it again.
|
|
function forgePlace()
|
|
FORGE.frame = (FORGE.frame or 0) + 1
|
|
if not FORGE.placing or (FORGE.gui == nil) or (FORGE.frame <= FORGE.placing + 1) then
|
|
return
|
|
end
|
|
FORGE.placing = false
|
|
local list = element("list")
|
|
local detail = element("detail")
|
|
local chosen = findClass(list, "selected") or findClass(list, "here")
|
|
local field = findClass(detail, "here")
|
|
local drop = element("drop")
|
|
|
|
if chosen then
|
|
scrollTo(list, chosen)
|
|
end
|
|
if field then
|
|
scrollTo(detail, field)
|
|
end
|
|
if drop and FORGE.dropRow and element(FORGE.dropRow) then
|
|
local row = element(FORGE.dropRow)
|
|
local panel = element("panel")
|
|
local top = offsetWithin(detail, panel) + offsetWithin(row, detail) - detail.scroll_top
|
|
local bottom = top + row.offset_height + drop.offset_height
|
|
|
|
if (bottom > panel.offset_height) and (top - drop.offset_height >= 0) then
|
|
-- Up: a negative top margin lifts it from its place under the row to above it,
|
|
-- and it stays with the row when the box scrolls.
|
|
drop.style["margin-top"] = tostring(-(drop.offset_height + row.offset_height)) .. "px"
|
|
end
|
|
drop.style.visibility = "visible"
|
|
end
|
|
end
|
|
|
|
|
|
function forgeDraw(pointerX, pointerY)
|
|
local room = forgeRoom()
|
|
|
|
forgePlace()
|
|
if FORGE.scene then
|
|
forgeDraw3D(pointerX, pointerY)
|
|
return
|
|
end
|
|
colorBackground(18, 18, 26, 255)
|
|
overlayClear()
|
|
if FORGE.video then
|
|
videoDraw(FORGE.video, 0, 0, overlayGetWidth(), overlayGetHeight())
|
|
end
|
|
-- Everything is drawn, including whatever is under the chrome. The panel is opaque and
|
|
-- slides, so it covers rather than hides: drag it aside and what was beneath it is there,
|
|
-- in the right place, and can be picked up. Clipping was the earlier answer and it lied.
|
|
for index, entry in ipairs(room.entities) do
|
|
local bx, by, bw, bh = forgeBounds(entry)
|
|
local look = forgeLook(entry)
|
|
local sprite = (look.kind == "sprite") and spriteFor(look.file or "") or nil
|
|
|
|
if look.kind == "text" then
|
|
local text = (entry.vars and entry.vars.text) or look.text or ""
|
|
|
|
colorForeground(look.r or 235, look.g or 235, look.b or 245, 255)
|
|
if text ~= "" then
|
|
fontPrint(entry.x, entry.y, text)
|
|
end
|
|
overlayBox(bx, by, bx + bw, by + bh)
|
|
elseif sprite ~= nil then
|
|
-- Turned as the entity's rz says, on the shared image: the canvas has time to spare.
|
|
-- A sheet shows its first frame, turned the same way.
|
|
if (look.frames or 1) > 1 then
|
|
spriteRotateFrame(sheetFor(look.file, look.frames), entry.rz or 0, 1)
|
|
spriteDrawRotatedFrame(sheetFor(look.file, look.frames), entry.x, entry.y, entry.scale or 1)
|
|
else
|
|
spriteRotate(sprite, entry.rz or 0)
|
|
spriteScale(sprite, entry.scale or 1)
|
|
spriteDraw(sprite, entry.x, entry.y, true)
|
|
end
|
|
elseif look.kind == "none" then
|
|
colorForeground(120, 120, 150, 255)
|
|
overlayBox(bx, by, bx + bw, by + bh)
|
|
overlayLine(bx, by, bx + bw, by + bh)
|
|
else
|
|
colorForeground(look.r or 180, look.g or 180, look.b or 190, 255)
|
|
fill(bx, by, bw, bh)
|
|
end
|
|
if (FORGE.mode ~= "tracks") and (index == FORGE.selected) then
|
|
colorForeground(255, 210, 70, 255)
|
|
overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2)
|
|
overlayBox(entry.x - HANDLE, entry.y - HANDLE, entry.x + HANDLE, entry.y + HANDLE)
|
|
forgeDrawHandle(forgeTurnHandle())
|
|
end
|
|
end
|
|
-- The walk areas, as outlines, so a room's floor can be seen against its picture; hotspot
|
|
-- outlines the same; and the polygon being drawn, open at its last corner.
|
|
colorForeground(90, 200, 120, 255)
|
|
for _, polygon in ipairs(room.walk or {}) do
|
|
for at = 1, #polygon - 1, 2 do
|
|
local nx = polygon[at + 2] or polygon[1]
|
|
local ny = polygon[at + 3] or polygon[2]
|
|
|
|
overlayLine(polygon[at], polygon[at + 1], nx, ny)
|
|
end
|
|
end
|
|
colorForeground(200, 160, 90, 255)
|
|
for _, entry in ipairs(room.entities) do
|
|
local entityType = FORGE.game.types[entry.type]
|
|
local spot = entityType and behaviourOf(entityType, "hotspot") or nil
|
|
local polygon = spot and authorNumbers(spot.polygon) or nil
|
|
|
|
for at = 1, (polygon and #polygon - 1 or 0), 2 do
|
|
overlayLine(polygon[at], polygon[at + 1], polygon[at + 2] or polygon[1], polygon[at + 3] or polygon[2])
|
|
end
|
|
end
|
|
if FORGE.drawing then
|
|
local points = FORGE.drawing.points
|
|
|
|
colorForeground(255, 230, 120, 255)
|
|
for at = 1, #points - 3, 2 do
|
|
overlayLine(points[at], points[at + 1], points[at + 2], points[at + 3])
|
|
end
|
|
for at = 1, #points - 1, 2 do
|
|
overlayBox(points[at] - 3, points[at + 1] - 3, points[at] + 3, points[at + 1] + 3)
|
|
end
|
|
end
|
|
-- Every track's box at the cursor, the selected one brighter, and the strip along the bottom.
|
|
for index, track in ipairs(room.tracks) do
|
|
local box = authorTrackBoxAt(track, FORGE.cursor)
|
|
|
|
if box then
|
|
if (FORGE.mode == "tracks") and (index == FORGE.track) then
|
|
colorForeground(255, 90, 90, 255)
|
|
else
|
|
colorForeground(150, 70, 70, 255)
|
|
end
|
|
overlayBox(box.x, box.y, box.x + box.w, box.y + box.h)
|
|
end
|
|
end
|
|
if FORGE.mode == "tracks" then
|
|
forgeDrawStrip()
|
|
end
|
|
-- The panel's drag tab.
|
|
local tx, ty, tw, th = forgeTab()
|
|
|
|
colorForeground(70, 70, 96, 255)
|
|
fill(tx, ty, tw, th)
|
|
colorForeground(255, 207, 74, 255)
|
|
overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16)
|
|
overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26)
|
|
overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36)
|
|
|
|
-- The pointer goes on after the chrome: what is drawn after guiDraw is composited over it.
|
|
guiDraw(FORGE.gui)
|
|
if pointerX ~= nil then
|
|
colorForeground(255, 220, 60, 255)
|
|
overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY)
|
|
overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7)
|
|
end
|
|
end
|
|
|
|
|
|
-- The scene draws itself; the overlay carries the selection, the rail, the tab, and the chrome.
|
|
function forgeDraw3D(pointerX, pointerY)
|
|
local room = forgeRoom()
|
|
|
|
forgeSceneSync()
|
|
-- The overlay is clear glass over the scene, whatever a flat room left the ground set to.
|
|
colorBackground(0, 0, 0, 0)
|
|
overlayClear()
|
|
for index, entry in ipairs(room.entities) do
|
|
local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0)
|
|
|
|
if inFront then
|
|
if (FORGE.mode ~= "tracks") and (index == FORGE.selected) then
|
|
colorForeground(255, 210, 70, 255)
|
|
overlayBox(sx - HANDLE - 2, sy - HANDLE - 2, sx + HANDLE + 2, sy + HANDLE + 2)
|
|
else
|
|
colorForeground(120, 120, 150, 255)
|
|
end
|
|
overlayBox(sx - HANDLE, sy - HANDLE, sx + HANDLE, sy + HANDLE)
|
|
fontPrint(sx + HANDLE + 3, sy - HANDLE, tostring(entry.id))
|
|
end
|
|
end
|
|
-- The gizmo on the selected entity.
|
|
if FORGE.mode ~= "tracks" then
|
|
for _, handle in ipairs(forgeGizmoHandles()) do
|
|
forgeDrawHandle(handle)
|
|
end
|
|
end
|
|
-- A rail is drawn as its polyline, its stops marked; every track with points is shown, the
|
|
-- selected one brighter.
|
|
for index, track in ipairs(room.tracks) do
|
|
local points = track.points or {}
|
|
local bright = (FORGE.mode == "tracks") and (index == FORGE.track)
|
|
|
|
for at = 1, #points - 1 do
|
|
local a, b = points[at], points[at + 1]
|
|
|
|
lineDraw(a.x or 0, a.y or 0, a.z or 0, b.x or 0, b.y or 0, b.z or 0, bright and 255 or 120, bright and 90 or 60, bright and 90 or 60)
|
|
end
|
|
for at, point in ipairs(points) do
|
|
local sx, sy, _, inFront = sceneProject(point.x or 0, point.y or 0, point.z or 0)
|
|
|
|
if inFront then
|
|
if bright and (at == FORGE.key) then
|
|
colorForeground(255, 210, 70, 255)
|
|
elseif point.stop then
|
|
colorForeground(255, 90, 90, 255)
|
|
else
|
|
colorForeground(150, 70, 70, 255)
|
|
end
|
|
overlayBox(sx - 4, sy - 4, sx + 4, sy + 4)
|
|
if point.stop then
|
|
fontPrint(sx + 6, sy - 6, tostring(point.stop))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if FORGE.mode == "tracks" then
|
|
forgeDrawStrip()
|
|
end
|
|
local tx, ty, tw, th = forgeTab()
|
|
|
|
colorForeground(70, 70, 96, 255)
|
|
fill(tx, ty, tw, th)
|
|
colorForeground(255, 207, 74, 255)
|
|
overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16)
|
|
overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26)
|
|
overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36)
|
|
-- The pointer goes on after the chrome: what is drawn after guiDraw is composited over it.
|
|
guiDraw(FORGE.gui)
|
|
if pointerX ~= nil then
|
|
colorForeground(255, 220, 60, 255)
|
|
overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY)
|
|
overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7)
|
|
end
|
|
end
|
|
|
|
|
|
-- The timeline: a ruler along the bottom with the selected track's keys as ticks and the cursor.
|
|
-- The ruler runs from the first key to the last, or over a screen's worth when there are none.
|
|
function forgeDrawStrip()
|
|
local track = forgeRoom().tracks[FORGE.track]
|
|
local left = FORGE.panelX + PANEL_W + TAB_W + STRIP_PAD
|
|
local right = overlayGetWidth() - STRIP_PAD
|
|
local top = overlayGetHeight() - STRIP_H
|
|
local first = 0
|
|
local last = 100
|
|
|
|
if left > right - 40 then
|
|
left = STRIP_PAD
|
|
end
|
|
local keys = track and forgeTrackKeys(track) or nil
|
|
|
|
if keys and #keys > 0 then
|
|
first = math.huge
|
|
last = -math.huge
|
|
for _, key in ipairs(keys) do
|
|
first = math.min(first, tonumber(key.at) or 0)
|
|
last = math.max(last, tonumber(key.at) or 0)
|
|
end
|
|
if last == first then
|
|
last = first + 1
|
|
end
|
|
end
|
|
local span = last - first
|
|
|
|
first = math.min(first, FORGE.cursor)
|
|
last = math.max(last, FORGE.cursor)
|
|
span = math.max(last - first, 1)
|
|
colorForeground(30, 30, 44, 255)
|
|
fill(left - STRIP_PAD, top, right - left + STRIP_PAD * 2, STRIP_H)
|
|
colorForeground(110, 110, 140, 255)
|
|
overlayLine(left, top + STRIP_H / 2, right, top + STRIP_H / 2)
|
|
for at, key in ipairs(keys or {}) do
|
|
local x = left + ((tonumber(key.at) or 0) - first) / span * (right - left)
|
|
|
|
if at == FORGE.key then
|
|
colorForeground(255, 210, 70, 255)
|
|
else
|
|
colorForeground(255, 90, 90, 255)
|
|
end
|
|
overlayLine(x, top + 6, x, top + STRIP_H - 6)
|
|
end
|
|
local cx = left + (FORGE.cursor - first) / span * (right - left)
|
|
|
|
colorForeground(255, 255, 255, 255)
|
|
overlayLine(cx, top + 2, cx, top + STRIP_H - 2)
|
|
fontPrint(left, top + 4, tostring(FORGE.cursor))
|
|
end
|
|
|
|
|
|
-- ===== Undo and redo ==========================================================================
|
|
--
|
|
-- The description is small and plain data, so undo is a copy of the whole thing taken before
|
|
-- each change, and undoing is putting the last copy back. Nothing has to know how to reverse
|
|
-- itself, which is what keeps every later feature from having to. Redo is the same the other
|
|
-- way: what undo took out is kept until something else changes.
|
|
|
|
-- Keeps a copy on one of the two stacks, oldest dropped once it is full.
|
|
local function keep(stack)
|
|
stack[#stack + 1] = copyOf(FORGE.game)
|
|
if #stack > UNDO_MAX then
|
|
table.remove(stack, 1)
|
|
end
|
|
end
|
|
|
|
|
|
-- Call before changing the description. A drag is one step, a typed value is one step. A change
|
|
-- made by hand is the end of the redo history: what was undone before it no longer applies.
|
|
function forgeRemember()
|
|
keep(FORGE.undo)
|
|
FORGE.redo = {}
|
|
end
|
|
|
|
|
|
-- Puts a copy back in place of the description, keeping the current one on the other stack.
|
|
local function swapTo(copy, other, message)
|
|
keep(other)
|
|
FORGE.game = copy
|
|
FORGE.editing = nil -- Whatever was being typed referred to the description just replaced.
|
|
FORGE.held = nil
|
|
FORGE.room = math.max(1, math.min(FORGE.room, #FORGE.game.rooms))
|
|
local room = forgeRoom()
|
|
|
|
FORGE.selected = FORGE.selected and math.min(FORGE.selected, #room.entities) or nil
|
|
if FORGE.selected == 0 then
|
|
FORGE.selected = nil
|
|
end
|
|
if FORGE.game.types[FORGE.typeName or ""] == nil then
|
|
FORGE.typeName = sortedKeys(FORGE.game.types)[1]
|
|
end
|
|
FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules))
|
|
FORGE.part = math.min(FORGE.part, #forgeParts(FORGE.game.rules[FORGE.rule] or {}))
|
|
FORGE.track = math.max(1, math.min(FORGE.track, #room.tracks))
|
|
FORGE.key = math.min(FORGE.key, #((room.tracks[FORGE.track] or {}).boxes or {}))
|
|
FORGE.dirty = true
|
|
FORGE.message = message
|
|
loadVideo()
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
function forgeUndo()
|
|
local was = table.remove(FORGE.undo)
|
|
|
|
if was == nil then
|
|
FORGE.message = "nothing to undo"
|
|
forgeRefresh()
|
|
return false
|
|
end
|
|
swapTo(was, FORGE.redo, "undone")
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function forgeRedo()
|
|
local again = table.remove(FORGE.redo)
|
|
|
|
if again == nil then
|
|
FORGE.message = "nothing to redo"
|
|
forgeRefresh()
|
|
return false
|
|
end
|
|
swapTo(again, FORGE.undo, "redone")
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- ===== Kinds ==================================================================================
|
|
--
|
|
-- A type is a look, behaviours, and vars. All of it is edited the way a rule's parameters are:
|
|
-- ENTER walks the fields, and each is typed. The fields come from the manifest -- a look's
|
|
-- parameters, a behaviour's parameters -- so a new look or behaviour is editable the moment it is
|
|
-- declared.
|
|
|
|
-- A name no other type has, from a stem.
|
|
local function uniqueType(stem)
|
|
local n = 1
|
|
|
|
if FORGE.game.types[stem] == nil then
|
|
return stem
|
|
end
|
|
while FORGE.game.types[stem .. n] ~= nil do
|
|
n = n + 1
|
|
end
|
|
|
|
return stem .. n
|
|
end
|
|
|
|
|
|
function forgeTypeAdd()
|
|
local name = uniqueType("thing")
|
|
|
|
forgeRemember()
|
|
FORGE.game.types[name] = { look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, vars = {}, behaviours = {} }
|
|
FORGE.typeName = name
|
|
FORGE.lastField = nil
|
|
FORGE.dirty = true
|
|
FORGE.message = "added type " .. name
|
|
forgeRefresh()
|
|
|
|
return name
|
|
end
|
|
|
|
|
|
function forgeTypeDuplicate()
|
|
local entityType = FORGE.game.types[FORGE.typeName or ""]
|
|
local name
|
|
|
|
if entityType == nil then
|
|
return nil
|
|
end
|
|
name = uniqueType(FORGE.typeName)
|
|
forgeRemember()
|
|
FORGE.game.types[name] = copyOf(entityType)
|
|
FORGE.typeName = name
|
|
FORGE.lastField = nil
|
|
FORGE.dirty = true
|
|
FORGE.message = "copied as " .. name
|
|
forgeRefresh()
|
|
|
|
return name
|
|
end
|
|
|
|
|
|
-- A type still placed somewhere cannot go: the entities would have nothing to be.
|
|
function forgeTypeDelete()
|
|
local name = FORGE.typeName
|
|
|
|
if (name == nil) or (FORGE.game.types[name] == nil) then
|
|
return false
|
|
end
|
|
for _, room in ipairs(FORGE.game.rooms) do
|
|
for _, entry in ipairs(room.entities) do
|
|
if entry.type == name then
|
|
FORGE.message = name .. " is placed in " .. room.name .. "; delete those first"
|
|
forgeRefresh()
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
forgeRemember()
|
|
FORGE.game.types[name] = nil
|
|
FORGE.typeName = sortedKeys(FORGE.game.types)[1]
|
|
FORGE.dirty = true
|
|
FORGE.message = "deleted type " .. name
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Renaming a type renames every entity of it and every rule that names it.
|
|
local function renameType(from, to)
|
|
for _, room in ipairs(FORGE.game.rooms) do
|
|
for _, entry in ipairs(room.entities) do
|
|
if entry.type == from then
|
|
entry.type = to
|
|
end
|
|
end
|
|
end
|
|
for _, rule in ipairs(FORGE.game.rules) do
|
|
if rule.each == from then
|
|
rule.each = to
|
|
end
|
|
for key, kind in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do
|
|
if (kind == "type") and (rule[key] == from) then
|
|
rule[key] = to
|
|
end
|
|
end
|
|
for _, list in ipairs({ rule.when or {}, rule.act or {} }) do
|
|
for _, item in ipairs(list) do
|
|
local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]]
|
|
|
|
for key, kind in pairs(entry and entry.params or {}) do
|
|
if (kind == "type") and (item[key] == from) then
|
|
item[key] = to
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for _, entityType in pairs(FORGE.game.types) do
|
|
for _, b in ipairs(entityType.behaviours or {}) do
|
|
local entry = AUTHOR.behaviours[b.kind]
|
|
|
|
for key, type_ in pairs(entry and entry.params or {}) do
|
|
if (type_ == "type") and (b[key] == from) then
|
|
b[key] = to
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- Fills in whatever a look or a behaviour declares and the table does not yet have, so a type
|
|
-- that was just typed in has a value for every parameter and compiles at once.
|
|
-- Fills in what a new thing must have to mean anything -- the entity self, a key, a switch, the
|
|
-- lit type -- and leaves the rest empty, so a shake shakes and a clip plays at full volume until
|
|
-- the author says otherwise.
|
|
-- A behaviour or a layer (sparse) fills in less than a rule's part: a key or a switch it is
|
|
-- not given it defaults for itself when the game runs -- a gun fires on the mouse button, a
|
|
-- pressed event needs the one it is for.
|
|
fillDefaults = function(t, params, sparse)
|
|
for key, kind in pairs(params or {}) do
|
|
if t[key] == nil then
|
|
if kind == "type" then
|
|
t[key] = FORGE.typeName or ""
|
|
elseif (FORGE_DEFAULTS[kind] ~= nil) and not (sparse and ((kind == "scancode") or (kind == "switch"))) then
|
|
t[key] = FORGE_DEFAULTS[kind]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- A type's fields, in the order the panel shows them: its name, its look and what that takes, its
|
|
-- vars, then its behaviours and what each of those takes. A behaviour's parameter is named
|
|
-- behaviour.parameter, which is how it is typed as well.
|
|
function forgeTypeFieldNames(entityType)
|
|
local names = { "name", "look" }
|
|
local look = AUTHOR.looks[(entityType.look or {}).kind]
|
|
|
|
for _, key in ipairs(sortedKeys(look and look.params or entityType.look)) do
|
|
if key ~= "kind" then
|
|
names[#names + 1] = "look." .. key
|
|
end
|
|
end
|
|
names[#names + 1] = "vars"
|
|
names[#names + 1] = "behaviours"
|
|
for _, b in ipairs(entityType.behaviours or {}) do
|
|
local entry = AUTHOR.behaviours[b.kind]
|
|
|
|
for _, key in ipairs(sortedKeys(entry and entry.params or b)) do
|
|
if key ~= "kind" then
|
|
names[#names + 1] = b.kind .. "." .. key
|
|
end
|
|
end
|
|
end
|
|
|
|
return names
|
|
end
|
|
|
|
|
|
-- Vars as one line: name=value, name=value.
|
|
local function varsText(vars)
|
|
local parts = {}
|
|
|
|
for _, key in ipairs(sortedKeys(vars)) do
|
|
parts[#parts + 1] = key .. "=" .. tostring(vars[key])
|
|
end
|
|
|
|
return table.concat(parts, ", ")
|
|
end
|
|
|
|
|
|
-- And back: a number is a number, true and false are themselves, anything else is text.
|
|
local function varsFrom(text)
|
|
local vars = {}
|
|
|
|
for name, value in string.gmatch(tostring(text), "([%w_]+)%s*=%s*([^,]*)") do
|
|
value = value:gsub("%s+$", "")
|
|
if tonumber(value) then
|
|
vars[name] = tonumber(value)
|
|
elseif value == "true" then
|
|
vars[name] = true
|
|
elseif value == "false" then
|
|
vars[name] = false
|
|
else
|
|
vars[name] = value
|
|
end
|
|
end
|
|
|
|
return vars
|
|
end
|
|
|
|
|
|
function forgeTypeGet(entityType, name)
|
|
local group, key = string.match(name, "^([^.]+)%.(.+)$")
|
|
|
|
if name == "name" then
|
|
return FORGE.typeName
|
|
elseif name == "look" then
|
|
return (entityType.look or {}).kind
|
|
elseif name == "vars" then
|
|
return varsText(entityType.vars)
|
|
elseif name == "behaviours" then
|
|
return listText(entityType.behaviours)
|
|
elseif group == "look" then
|
|
return tableText((entityType.look or {})[key])
|
|
elseif group ~= nil then
|
|
local b = behaviourOf(entityType, group)
|
|
|
|
return b and tableText(b[key]) or nil
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- What a typed value becomes for a parameter of a manifest type: tables are parsed, the rest
|
|
-- is as typed.
|
|
local function valueFor(params, key, value)
|
|
if ((params or {})[key] == "table") and (type(value) == "string") then
|
|
return tableFrom(value)
|
|
end
|
|
|
|
return value
|
|
end
|
|
|
|
|
|
function forgeTypeSet(entityType, name, value)
|
|
local group, key = string.match(name, "^([^.]+)%.(.+)$")
|
|
|
|
entityType.look = entityType.look or {}
|
|
if name == "name" then
|
|
if (value ~= "") and (value ~= FORGE.typeName) and (FORGE.game.types[value] == nil) then
|
|
renameType(FORGE.typeName, value)
|
|
FORGE.game.types[value] = entityType
|
|
FORGE.game.types[FORGE.typeName] = nil
|
|
FORGE.typeName = value
|
|
end
|
|
elseif name == "look" then
|
|
if value == entityType.look.kind then
|
|
return
|
|
elseif AUTHOR.looks[value] ~= nil then
|
|
-- A new look starts clean: a box's size and colour mean nothing to a sprite.
|
|
entityType.look = { kind = value }
|
|
fillDefaults(entityType.look, AUTHOR.looks[value].params, true)
|
|
else
|
|
FORGE.message = "no look called " .. tostring(value)
|
|
end
|
|
elseif name == "vars" then
|
|
entityType.vars = varsFrom(value)
|
|
elseif name == "behaviours" then
|
|
FORGE.stash[entityType] = FORGE.stash[entityType] or {}
|
|
entityType.behaviours = listFrom(value, entityType.behaviours, AUTHOR.behaviours, FORGE.stash[entityType], "behaviour")
|
|
elseif group == "look" then
|
|
entityType.look[key] = valueFor((AUTHOR.looks[entityType.look.kind] or {}).params, key, value)
|
|
elseif group ~= nil then
|
|
local b = behaviourOf(entityType, group)
|
|
|
|
if b ~= nil then
|
|
b[key] = valueFor((AUTHOR.behaviours[group] or {}).params, key, value)
|
|
end
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- ===== Entities ===============================================================================
|
|
|
|
-- A name no entity in the room has, from a stem.
|
|
local function uniqueId(stem)
|
|
local taken = {}
|
|
local n = 1
|
|
|
|
for _, entry in ipairs(forgeRoom().entities) do
|
|
taken[entry.id or ""] = true
|
|
end
|
|
if not taken[stem] then
|
|
return stem
|
|
end
|
|
while taken[stem .. n] do
|
|
n = n + 1
|
|
end
|
|
|
|
return stem .. n
|
|
end
|
|
|
|
|
|
-- A new entity of the selected type in the middle of the canvas, clear of the panel, selected and
|
|
-- ready to be moved. With no types yet, one is made first.
|
|
function forgeEntityAdd(x, y)
|
|
local room = forgeRoom()
|
|
local typeName = FORGE.typeName
|
|
|
|
if (typeName == nil) or (FORGE.game.types[typeName] == nil) then
|
|
typeName = forgeTypeAdd()
|
|
end
|
|
forgeRemember()
|
|
if FORGE.scene then
|
|
-- In a scene the new entity stands on the floor under the middle of the canvas, or at the
|
|
-- origin when nothing is there to stand on.
|
|
local gx, gz = forgeGroundPoint((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2, overlayGetHeight() / 2, 0)
|
|
|
|
room.entities[#room.entities + 1] = {
|
|
type = typeName,
|
|
id = uniqueId(typeName),
|
|
x = gx and (math.floor(gx * 10 + 0.5) / 10) or 0,
|
|
y = 0,
|
|
z = gz and (math.floor(gz * 10 + 0.5) / 10) or 0
|
|
}
|
|
else
|
|
room.entities[#room.entities + 1] = {
|
|
type = typeName,
|
|
id = uniqueId(typeName),
|
|
x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)),
|
|
y = math.floor(y or (overlayGetHeight() / 2))
|
|
}
|
|
end
|
|
FORGE.dirty = true
|
|
FORGE.message = "added " .. room.entities[#room.entities].id
|
|
forgeSelect(#room.entities)
|
|
|
|
return #room.entities
|
|
end
|
|
|
|
|
|
-- A copy of the selected entity, a little to one side so the two can be told apart.
|
|
function forgeEntityDuplicate()
|
|
local room = forgeRoom()
|
|
local entry = room.entities[FORGE.selected]
|
|
local twin
|
|
|
|
if entry == nil then
|
|
return nil
|
|
end
|
|
forgeRemember()
|
|
twin = copyOf(entry)
|
|
twin.id = uniqueId(entry.type)
|
|
twin.x = entry.x + 20
|
|
twin.y = entry.y + 20
|
|
room.entities[#room.entities + 1] = twin
|
|
FORGE.dirty = true
|
|
FORGE.message = "copied " .. entry.id .. " as " .. twin.id
|
|
forgeSelect(#room.entities)
|
|
|
|
return #room.entities
|
|
end
|
|
|
|
|
|
function forgeEntityDelete()
|
|
local room = forgeRoom()
|
|
local entry = room.entities[FORGE.selected]
|
|
|
|
if entry == nil then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
table.remove(room.entities, FORGE.selected)
|
|
FORGE.dirty = true
|
|
FORGE.message = "deleted " .. tostring(entry.id)
|
|
if #room.entities == 0 then
|
|
forgeSelect(nil)
|
|
else
|
|
forgeSelect(math.min(FORGE.selected, #room.entities))
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- An entity's fields: what every entity has, then the vars its type declares, overridable here.
|
|
function forgeEntityFieldNames(entry)
|
|
local names = { "id", "type", "x", "y", "z" }
|
|
|
|
if FORGE.scene then
|
|
names[#names + 1] = "rx"
|
|
names[#names + 1] = "ry"
|
|
end
|
|
names[#names + 1] = "rz"
|
|
names[#names + 1] = "scale"
|
|
local entityType = FORGE.game.types[entry.type] or {}
|
|
|
|
for _, key in ipairs(sortedKeys(entityType.vars)) do
|
|
names[#names + 1] = "vars." .. key
|
|
end
|
|
|
|
return names
|
|
end
|
|
|
|
|
|
function forgeEntityGet(entry, name)
|
|
local key = string.match(name, "^vars%.(.+)$")
|
|
|
|
if key ~= nil then
|
|
local entityType = FORGE.game.types[entry.type] or {}
|
|
|
|
if entry.vars and entry.vars[key] ~= nil then
|
|
return entry.vars[key]
|
|
end
|
|
|
|
return (entityType.vars or {})[key]
|
|
end
|
|
if name == "scale" then
|
|
return entry.scale or 1
|
|
end
|
|
|
|
return entry[name]
|
|
end
|
|
|
|
|
|
-- Renaming an entity renames it in every rule that talks about it, which is what the author meant.
|
|
local function renameEverywhere(from, to)
|
|
for _, rule in ipairs(FORGE.game.rules) do
|
|
for _, list in ipairs({ rule.when or {}, rule.act or {} }) do
|
|
for _, item in ipairs(list) do
|
|
local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]]
|
|
|
|
for key, kind in pairs(entry and entry.params or {}) do
|
|
if (kind == "entity") and (item[key] == from) then
|
|
item[key] = to
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
function forgeEntitySet(entry, name, value)
|
|
local key = string.match(name, "^vars%.(.+)$")
|
|
|
|
if name == "id" then
|
|
if (value ~= "") and (value ~= entry.id) then
|
|
renameEverywhere(entry.id, value)
|
|
entry.id = value
|
|
end
|
|
elseif name == "type" then
|
|
if FORGE.game.types[value] ~= nil then
|
|
entry.type = value
|
|
else
|
|
FORGE.message = "no type called " .. tostring(value)
|
|
end
|
|
elseif (name == "x") or (name == "y") or (name == "z") then
|
|
if type(value) == "number" then
|
|
entry[name] = FORGE.scene and (math.floor(value * 10 + 0.5) / 10) or math.floor(value)
|
|
end
|
|
elseif (name == "rx") or (name == "ry") or (name == "rz") then
|
|
if type(value) == "number" then
|
|
entry[name] = (value ~= 0) and (math.floor(value + 0.5) % 360) or nil
|
|
end
|
|
elseif name == "scale" then
|
|
if (type(value) == "number") and (value > 0) then
|
|
entry.scale = (value ~= 1) and value or nil
|
|
end
|
|
elseif key ~= nil then
|
|
entry.vars = entry.vars or {}
|
|
entry.vars[key] = value
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- ===== Rooms ==================================================================================
|
|
|
|
function forgeRoomGo(index)
|
|
FORGE.room = math.max(1, math.min(index, #FORGE.game.rooms))
|
|
FORGE.selected = nil
|
|
FORGE.track = 1
|
|
FORGE.key = 0
|
|
FORGE.held = nil
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
function forgeRoomAdd()
|
|
local n = #FORGE.game.rooms + 1
|
|
|
|
forgeRemember()
|
|
FORGE.game.rooms[n] = { name = "room" .. n, entities = {}, tracks = {} }
|
|
FORGE.dirty = true
|
|
FORGE.message = "added room" .. n
|
|
forgeRoomGo(n)
|
|
|
|
return n
|
|
end
|
|
|
|
|
|
-- ===== Drawing polygons =======================================================================
|
|
--
|
|
-- A walk area, or a hotspot's outline, is drawn on the canvas corner by corner: V starts one,
|
|
-- every press adds a corner, V or ENTER closes it. It goes to the selected entity's type when
|
|
-- that type has a hotspot behaviour, and to the room's walk areas otherwise.
|
|
|
|
function forgePolygonBegin()
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
local entityType = entry and FORGE.game.types[entry.type] or nil
|
|
local spot = entityType and behaviourOf(entityType, "hotspot") or nil
|
|
|
|
-- The panel steps aside while corners are pressed, since a press under it is the panel's.
|
|
forgePanelShow(false)
|
|
FORGE.drawing = { points = {}, hotspot = spot }
|
|
FORGE.message = spot and ("drawing " .. entry.id .. "'s outline: press each corner") or "drawing a walk area: press each corner"
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
function forgePolygonClose()
|
|
local drawing = FORGE.drawing
|
|
local room = forgeRoom()
|
|
|
|
FORGE.drawing = nil
|
|
forgePanelShow(true)
|
|
if #drawing.points < 6 then
|
|
FORGE.message = "a polygon needs three corners"
|
|
forgeRefresh()
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
if drawing.hotspot then
|
|
local entry = forgeRoom().entities[FORGE.selected]
|
|
local entityType = FORGE.game.types[entry.type]
|
|
|
|
behaviourOf(entityType, "hotspot").polygon = drawing.points
|
|
FORGE.message = "outline drawn"
|
|
else
|
|
room.walk = room.walk or {}
|
|
room.walk[#room.walk + 1] = drawing.points
|
|
FORGE.message = "walk area " .. #room.walk .. " drawn"
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- ===== The event sheet ========================================================================
|
|
--
|
|
-- Rules are where someone with little programming skill actually spends their time, so this is the
|
|
-- half of the editor that matters. The vocabulary comes from AUTHOR, so a rule editor never needs
|
|
-- changing when a condition, an action, or an event is added: it lists whatever the manifest
|
|
-- declares.
|
|
|
|
-- Every condition and action of a rule, flattened, so one index walks the whole thing.
|
|
function forgeParts(rule)
|
|
local parts = {}
|
|
|
|
for _, item in ipairs(rule.when or {}) do
|
|
parts[#parts + 1] = { kind = "when", item = item }
|
|
end
|
|
for _, item in ipairs((rule.when or {}).any or {}) do
|
|
parts[#parts + 1] = { kind = "any", item = item }
|
|
end
|
|
for _, item in ipairs(rule.act or {}) do
|
|
parts[#parts + 1] = { kind = "act", item = item }
|
|
end
|
|
|
|
return parts
|
|
end
|
|
|
|
|
|
-- A new condition or action, with a value for each parameter the manifest declares, so it compiles
|
|
-- the moment it is added rather than only once every field has been filled in.
|
|
function forgeMakePart(set, name)
|
|
local entry = set[name]
|
|
local item = { name }
|
|
|
|
if entry == nil then
|
|
return nil
|
|
end
|
|
fillDefaults(item, entry.params)
|
|
|
|
return item
|
|
end
|
|
|
|
|
|
-- Adds a condition ("when"), a condition in the rule's any group ("any"), or an action ("act").
|
|
function forgeRuleAdd(partKind, name)
|
|
local rule = FORGE.game.rules[FORGE.rule]
|
|
local set = (partKind == "act") and AUTHOR.actions or AUTHOR.conditions
|
|
local item = forgeMakePart(set, name)
|
|
local list
|
|
|
|
if (rule == nil) or (item == nil) then
|
|
FORGE.message = "no such " .. partKind
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
rule.when = rule.when or {}
|
|
rule.act = rule.act or {}
|
|
if partKind == "any" then
|
|
rule.when.any = rule.when.any or {}
|
|
list = rule.when.any
|
|
else
|
|
list = rule[partKind]
|
|
end
|
|
list[#list + 1] = item
|
|
FORGE.dirty = true
|
|
FORGE.lastField = nil
|
|
FORGE.message = "added " .. name
|
|
|
|
-- Select what was just added, so its parameters can be set straight away. Conditions come
|
|
-- before actions in the flattened list, so adding one shifts every action along: the new
|
|
-- part is found by identity rather than by assuming it went on the end.
|
|
for at, part in ipairs(forgeParts(rule)) do
|
|
if part.item == item then
|
|
FORGE.part = at
|
|
end
|
|
end
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function forgePartDelete()
|
|
local rule = FORGE.game.rules[FORGE.rule]
|
|
local parts = forgeParts(rule)
|
|
local part = parts[FORGE.part]
|
|
local list
|
|
|
|
if part == nil then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
if part.kind == "any" then
|
|
list = rule.when.any
|
|
else
|
|
list = rule[part.kind]
|
|
end
|
|
for i = 1, #list do
|
|
if list[i] == part.item then
|
|
table.remove(list, i)
|
|
break
|
|
end
|
|
end
|
|
FORGE.part = math.min(FORGE.part, #forgeParts(rule))
|
|
FORGE.dirty = true
|
|
FORGE.message = "deleted"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Changes one parameter of the selected condition or action. Numbers arrive as numbers so that a
|
|
-- description keeps compiling to the same thing whether it was typed or dragged.
|
|
function forgePartSet(key, value)
|
|
local parts = forgeParts(FORGE.game.rules[FORGE.rule])
|
|
local part = parts[FORGE.part]
|
|
|
|
if part == nil then
|
|
return false
|
|
end
|
|
part.item[key] = value
|
|
FORGE.dirty = true
|
|
FORGE.message = key .. " = " .. tostring(value)
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function forgeRuleNew(note)
|
|
forgeRemember()
|
|
FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", on = "frame", when = {}, act = {} }
|
|
FORGE.rule = #FORGE.game.rules
|
|
FORGE.part = 0
|
|
FORGE.lastField = nil
|
|
FORGE.dirty = true
|
|
FORGE.message = "rule added: E picks what it is on"
|
|
forgeRefresh()
|
|
|
|
return FORGE.rule
|
|
end
|
|
|
|
|
|
function forgeRuleDelete()
|
|
if FORGE.game.rules[FORGE.rule] == nil then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
table.remove(FORGE.game.rules, FORGE.rule)
|
|
FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules))
|
|
FORGE.part = 0
|
|
FORGE.dirty = true
|
|
FORGE.message = "rule deleted"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Moves the selected rule up or down the sheet. Rules run in order, and "the readout, every
|
|
-- frame" wants to be last, so the order is part of the game.
|
|
function forgeRuleMove(by)
|
|
local rules = FORGE.game.rules
|
|
local to = FORGE.rule + by
|
|
|
|
if (rules[FORGE.rule] == nil) or (rules[to] == nil) then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
rules[FORGE.rule], rules[to] = rules[to], rules[FORGE.rule]
|
|
FORGE.rule = to
|
|
FORGE.dirty = true
|
|
FORGE.message = "rule moved"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Sets what a rule is triggered by. The old event's filter values go, since they mean nothing to
|
|
-- the new one, and the new one's start empty.
|
|
function forgeRuleOn(name)
|
|
local rule = FORGE.game.rules[FORGE.rule]
|
|
|
|
if (rule == nil) or (AUTHOR.events[name] == nil) then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
for key in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do
|
|
rule[key] = nil
|
|
end
|
|
rule.on = name
|
|
for key, kind in pairs(AUTHOR.events[name].filter) do
|
|
if kind == "type" then
|
|
rule[key] = FORGE.typeName
|
|
end
|
|
end
|
|
FORGE.part = 0
|
|
FORGE.lastField = nil
|
|
FORGE.dirty = true
|
|
FORGE.message = "on " .. name
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- One condition or action as a line: its name, then its parameters in a fixed order so the same
|
|
-- rule always reads the same way.
|
|
function forgePartText(item)
|
|
local out = {}
|
|
|
|
for _, key in ipairs(forgeFieldNames(item)) do
|
|
if item[key] ~= nil then
|
|
out[#out + 1] = key .. ": " .. tostring(item[key])
|
|
end
|
|
end
|
|
|
|
return item[1] .. (( #out > 0) and (": " .. table.concat(out, ", ")) or "")
|
|
end
|
|
|
|
|
|
-- A rule's own fields: its note, what it is on and that event's filters, each, room, and the
|
|
-- flags a sequence can carry.
|
|
function forgeRuleFieldNames(rule)
|
|
local names = { "note", "on" }
|
|
local event = AUTHOR.events[rule.on or "frame"]
|
|
|
|
for _, key in ipairs(sortedKeys(event and event.filter or {})) do
|
|
names[#names + 1] = key
|
|
end
|
|
names[#names + 1] = "each"
|
|
names[#names + 1] = "room"
|
|
names[#names + 1] = "controls"
|
|
names[#names + 1] = "interrupt"
|
|
|
|
return names
|
|
end
|
|
|
|
|
|
function forgeMode(mode)
|
|
FORGE.mode = mode
|
|
FORGE.lastField = nil
|
|
FORGE.picker = nil
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- ===== Tracks and the timeline ================================================================
|
|
--
|
|
-- A track is keys by disc frame or by time. The cursor is where the timeline stands; the canvas
|
|
-- shows the frame of the disc layer's video under it and every track's box there.
|
|
|
|
-- A new track: boxes over the picture, or a rail of points in a scene -- or, with waves, a
|
|
-- track of spawns, keyed by time or frame like the others, or by a rail's stops.
|
|
function forgeTrackNew(name, waves)
|
|
local room = forgeRoom()
|
|
|
|
forgeRemember()
|
|
if waves then
|
|
room.tracks[#room.tracks + 1] = { name = name or "waves", key = FORGE.scene and "time" or "frame", spawns = {} }
|
|
elseif FORGE.scene then
|
|
room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "time", points = {} }
|
|
else
|
|
room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "frame", boxes = {} }
|
|
end
|
|
FORGE.track = #room.tracks
|
|
FORGE.key = 0
|
|
FORGE.dirty = true
|
|
FORGE.message = waves and "waves track added: K adds a wave at the cursor; key stop ties waves to a rail's stops" or "track added: K adds a key at the cursor"
|
|
forgeRefresh()
|
|
|
|
return FORGE.track
|
|
end
|
|
|
|
|
|
function forgeTrackDelete()
|
|
local room = forgeRoom()
|
|
|
|
if room.tracks[FORGE.track] == nil then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
table.remove(room.tracks, FORGE.track)
|
|
FORGE.track = math.max(1, math.min(FORGE.track, #room.tracks))
|
|
FORGE.key = 0
|
|
FORGE.dirty = true
|
|
FORGE.message = "track deleted"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- A track's keys, whichever kind it holds: boxes over a picture, points along a rail, or waves.
|
|
function forgeTrackKeys(track)
|
|
return track.boxes or track.points or track.spawns or {}
|
|
end
|
|
|
|
|
|
-- Keys in order: by their moment, or by their stop's name on a track keyed by stops.
|
|
function forgeKeyOrder(a, b)
|
|
if type(a.at) == type(b.at) then
|
|
return a.at < b.at
|
|
end
|
|
|
|
return tostring(a.at) < tostring(b.at)
|
|
end
|
|
|
|
|
|
-- The selected track's box at the cursor, interpolated, or nil.
|
|
function forgeCursorBox()
|
|
local track = forgeRoom().tracks[FORGE.track]
|
|
|
|
if track == nil then
|
|
return nil
|
|
end
|
|
|
|
return authorTrackBoxAt(track, FORGE.cursor)
|
|
end
|
|
|
|
|
|
-- The key exactly at the cursor; made from the interpolated box, or the nearest key, or a default,
|
|
-- when asked to and there is none.
|
|
function forgeKeyAtCursor(make)
|
|
local track = forgeRoom().tracks[FORGE.track]
|
|
local box
|
|
|
|
if track == nil then
|
|
return nil
|
|
end
|
|
for at, key in ipairs(track.boxes) do
|
|
if key.at == FORGE.cursor then
|
|
FORGE.key = at
|
|
return key
|
|
end
|
|
end
|
|
if not make then
|
|
return nil
|
|
end
|
|
box = forgeCursorBox()
|
|
if box == nil then
|
|
local nearest
|
|
|
|
for _, key in ipairs(track.boxes) do
|
|
if (nearest == nil) or (math.abs(key.at - FORGE.cursor) < math.abs(nearest.at - FORGE.cursor)) then
|
|
nearest = key
|
|
end
|
|
end
|
|
box = nearest or { x = (FORGE.panelX + PANEL_W + overlayGetWidth()) / 2 - 30, y = overlayGetHeight() / 2 - 30, w = 60, h = 60 }
|
|
end
|
|
local key = { at = FORGE.cursor, x = math.floor(box.x), y = math.floor(box.y), w = math.floor(box.w), h = math.floor(box.h) }
|
|
|
|
track.boxes[#track.boxes + 1] = key
|
|
table.sort(track.boxes, function(a, b) return a.at < b.at end)
|
|
for at, item in ipairs(track.boxes) do
|
|
if item == key then
|
|
FORGE.key = at
|
|
end
|
|
end
|
|
FORGE.dirty = true
|
|
|
|
return key
|
|
end
|
|
|
|
|
|
function forgeKeyAdd()
|
|
local track = forgeRoom().tracks[FORGE.track]
|
|
|
|
if track == nil then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
if track.spawns then
|
|
-- A wave at the cursor -- or at a stop still to be named -- of one of the first type, at
|
|
-- the middle of the canvas, to be typed into shape.
|
|
local key = { at = (track.key == "stop") and "stop" or FORGE.cursor, type = sortedKeys(FORGE.game.types)[1], count = 1 }
|
|
|
|
if FORGE.scene then
|
|
key.x, key.y, key.z = 0, 0, 0
|
|
else
|
|
key.x, key.y = math.floor((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2), math.floor(overlayGetHeight() / 2)
|
|
end
|
|
track.spawns[#track.spawns + 1] = key
|
|
table.sort(track.spawns, forgeKeyOrder)
|
|
for at, item in ipairs(track.spawns) do
|
|
if item == key then
|
|
FORGE.key = at
|
|
end
|
|
end
|
|
FORGE.dirty = true
|
|
elseif track.points then
|
|
-- A rail point is put where the editor camera is, looking where it looks: stand where the
|
|
-- player should and press K.
|
|
local cx, cy, cz = nodeGetPosition(FORGE.scene.camera)
|
|
local fx, fy, fz = sceneFocus()
|
|
local point = { at = FORGE.cursor, x = math.floor(cx * 10 + 0.5) / 10, y = math.floor(cy * 10 + 0.5) / 10, z = math.floor(cz * 10 + 0.5) / 10, look = { fx, fy, fz } }
|
|
|
|
track.points[#track.points + 1] = point
|
|
table.sort(track.points, function(a, b) return a.at < b.at end)
|
|
for at, item in ipairs(track.points) do
|
|
if item == point then
|
|
FORGE.key = at
|
|
end
|
|
end
|
|
FORGE.dirty = true
|
|
else
|
|
forgeKeyAtCursor(true)
|
|
end
|
|
FORGE.lastField = nil
|
|
FORGE.message = "key at " .. FORGE.cursor
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function forgeKeyDelete()
|
|
local track = forgeRoom().tracks[FORGE.track]
|
|
local keys = track and forgeTrackKeys(track) or nil
|
|
|
|
if (keys == nil) or (keys[FORGE.key] == nil) then
|
|
return false
|
|
end
|
|
forgeRemember()
|
|
table.remove(keys, FORGE.key)
|
|
FORGE.key = math.min(FORGE.key, #keys)
|
|
FORGE.dirty = true
|
|
FORGE.message = "key deleted"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Moves the cursor and shows the disc's frame there.
|
|
function forgeCursorTo(at)
|
|
FORGE.cursor = math.max(0, math.floor(at))
|
|
if FORGE.video then
|
|
videoSeek(FORGE.video, FORGE.cursor)
|
|
end
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- ===== Pickers ================================================================================
|
|
--
|
|
-- Choosing a condition, an action, an event, a look, or a behaviour is choosing from the manifest,
|
|
-- and the manifest carries a line of help for each. The list takes the panel over while it is up.
|
|
|
|
-- Opens a list. set is a manifest table (AUTHOR.conditions and its kind), or a list of
|
|
-- { name, help } items; choose is called with the name picked. Typing narrows the list.
|
|
function forgePickBegin(title, set, choose)
|
|
local items = {}
|
|
|
|
if set[1] ~= nil then
|
|
items = set
|
|
else
|
|
for _, name in ipairs(sortedKeys(set)) do
|
|
items[#items + 1] = { name = name, help = set[name].help }
|
|
end
|
|
end
|
|
if #items == 0 then
|
|
FORGE.message = "nothing to choose from"
|
|
forgeRefresh()
|
|
return false
|
|
end
|
|
FORGE.picker = { title = title, items = items, at = 1, choose = choose, filter = "" }
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Opens the list a field's values come from, if it has one. Answers whether it did.
|
|
function forgeFieldList(fields, field)
|
|
local items, kind, free = forgeFieldChoices(field)
|
|
local was = fields.get(field)
|
|
|
|
if items == nil then
|
|
return false
|
|
end
|
|
-- ENTER on a row takes it and moves on to the next field, as ENTER on a typed value does, so
|
|
-- the form is still walked one ENTER per field; a click takes it and stays. The value the
|
|
-- field already has is left alone: choosing a type's look again would empty its parameters.
|
|
forgePickBegin(((kind == "file") and "a file for " or "a value for ") .. field, items, function(word, onward)
|
|
local value = word
|
|
|
|
if word == FILE_NONE then
|
|
value = nil
|
|
elseif (kind == "boolean") or (kind == "flag") then
|
|
value = (word == "true")
|
|
end
|
|
if word ~= tostring(was) then
|
|
fields.set(field, value)
|
|
end
|
|
FORGE.message = field .. " = " .. word
|
|
if onward and (FORGE.editing ~= nil) and (FORGE.editing.key == field) then
|
|
forgeEditNext()
|
|
else
|
|
FORGE.editing = nil
|
|
end
|
|
end)
|
|
if FORGE.picker then
|
|
local found = nil
|
|
|
|
FORGE.picker.free = free
|
|
FORGE.picker.field = true -- Drawn under the field's row, not over the panel's list.
|
|
for at, item in ipairs(FORGE.picker.items) do
|
|
if item.name == tostring(was) then
|
|
found = at
|
|
end
|
|
end
|
|
-- A value the list does not know -- a room not made yet, a word of its own -- is offered
|
|
-- as it is, so walking past the field with ENTER keeps it.
|
|
if (found == nil) and (was ~= nil) then
|
|
found = math.min(2, #FORGE.picker.items + 1)
|
|
table.insert(FORGE.picker.items, found, { name = tostring(was), help = "as it is" })
|
|
end
|
|
FORGE.picker.at = found or 1
|
|
forgeRefresh() -- Drawn again as a dropdown: forgePickBegin drew it over the list.
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- The picker's items that match what has been typed. A free list -- a state -- offers what was
|
|
-- typed first, when it is not a name already, so ENTER takes a new word as readily as a known.
|
|
function forgePickItems()
|
|
local picker = FORGE.picker
|
|
local filter = picker.filter:lower()
|
|
local shown = {}
|
|
|
|
if picker.free and (picker.filter ~= "") then
|
|
local known = false
|
|
|
|
for _, item in ipairs(picker.items) do
|
|
known = known or (item.name == picker.filter)
|
|
end
|
|
if not known then
|
|
shown[1] = { name = picker.filter, help = "as typed" }
|
|
end
|
|
end
|
|
|
|
-- What was typed exactly comes first, then what begins with it, then what merely contains
|
|
-- it, so "miss" lands on miss and not on branchMissed.
|
|
for _, rank in ipairs({ 1, 2, 3 }) do
|
|
for _, item in ipairs(picker.items) do
|
|
local name = item.name:lower()
|
|
local at = (filter ~= "") and name:find(filter, 1, true) or nil
|
|
local mine = (filter == "") and 3 or ((name == filter) and 1 or ((at == 1) and 2 or (at and 3 or nil)))
|
|
|
|
if mine == rank then
|
|
shown[#shown + 1] = item
|
|
end
|
|
end
|
|
end
|
|
|
|
return shown
|
|
end
|
|
|
|
|
|
-- A key while a list is up. Answers whether it was taken.
|
|
function forgePickKey(scancode, keysym)
|
|
local picker = FORGE.picker
|
|
|
|
if picker == nil then
|
|
return false
|
|
end
|
|
local shown = forgePickItems()
|
|
|
|
if scancode == SCANCODE.DOWN.value then
|
|
picker.at = math.min(picker.at + 1, #shown)
|
|
elseif scancode == SCANCODE.UP.value then
|
|
picker.at = math.max(picker.at - 1, 1)
|
|
elseif scancode == SCANCODE.RETURN.value then
|
|
if shown[picker.at] then
|
|
FORGE.picker = nil
|
|
picker.choose(shown[picker.at].name, true)
|
|
end
|
|
elseif scancode == SCANCODE.ESCAPE.value then
|
|
FORGE.picker = nil
|
|
FORGE.message = "left it"
|
|
elseif scancode == SCANCODE.BACKSPACE.value then
|
|
picker.filter = string.sub(picker.filter, 1, -2)
|
|
picker.at = 1
|
|
elseif keysym and (keysym >= 32) and (keysym < 127) and not FORGE.textInput then
|
|
picker.filter = picker.filter .. string.char(keysym)
|
|
picker.at = 1
|
|
else
|
|
return true
|
|
end
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- The files under the game's directory a field could name, as picker items.
|
|
function forgeFileItems()
|
|
local found = {}
|
|
local seen = {}
|
|
|
|
local function walk(directory, prefix, depth)
|
|
-- lfs.dir hands back its iterator and the directory it walks; both go to the for.
|
|
local ok, iterator, handle = pcall(lfs.dir, directory)
|
|
|
|
if not ok then
|
|
return
|
|
end
|
|
for name in iterator, handle do
|
|
if (name:sub(1, 1) ~= ".") and (name ~= "Singe") and (name ~= "data") then
|
|
local path = prefix .. name
|
|
local mode = lfs.attributes(directory .. "/" .. name, "mode")
|
|
|
|
if (mode == "directory") and (depth < FILE_DEPTH) then
|
|
seen[path .. "/"] = true
|
|
walk(directory .. "/" .. name, path .. "/", depth + 1)
|
|
elseif mode == "file" then
|
|
local extension = name:match("%.([%w]+)$")
|
|
|
|
if extension and FILE_KINDS[extension:lower()] then
|
|
found[#found + 1] = { name = path, help = extension:lower() }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
walk(".", "", 0)
|
|
-- The kit travels inside Forge.game, which the walk from the games directory cannot see.
|
|
if not seen["Forge/kit/"] then
|
|
walk("Forge/kit", "Forge/kit/", 0)
|
|
end
|
|
table.sort(found, function(a, b) return a.name < b.name end)
|
|
|
|
return found
|
|
end
|
|
|
|
|
|
-- Whether a field names a file, by the manifest's word for it.
|
|
-- What a field is, for the list it opens: the manifest's type for a parameter ("file",
|
|
-- "boolean", "scancode", "switch", "room", "type", "entity", "state", "track"), a list of the
|
|
-- words a parameter takes (the manifest's choices for it), or nil for a value typed free. The
|
|
-- editor's own fields -- a type's look, a rule's event, an entity's type, a track's key -- are
|
|
-- typed here as well.
|
|
function forgeFieldKind(name)
|
|
local group, key = string.match(name, "^([^.]+)%.(.+)$")
|
|
local entry = nil
|
|
local param = name
|
|
|
|
if FORGE.mode == "types" then
|
|
local entityType = FORGE.game.types[FORGE.typeName or ""]
|
|
|
|
if name == "look" then
|
|
return AUTHOR.looks
|
|
elseif (group == "look") and entityType and entityType.look then
|
|
entry = AUTHOR.looks[entityType.look.kind]
|
|
param = key
|
|
elseif group then
|
|
entry = AUTHOR.behaviours[group]
|
|
param = key
|
|
end
|
|
elseif FORGE.mode == "rules" then
|
|
if FORGE.part == 0 then
|
|
local rule = FORGE.game.rules[FORGE.rule] or {}
|
|
local event = AUTHOR.events[rule.on or "frame"]
|
|
|
|
if name == "on" then
|
|
return AUTHOR.events
|
|
elseif name == "each" then
|
|
return "type"
|
|
elseif name == "room" then
|
|
return "room"
|
|
elseif (name == "controls") or (name == "interrupt") then
|
|
return "flag"
|
|
end
|
|
|
|
return event and event.filter[name] or nil
|
|
end
|
|
local part = forgeParts(FORGE.game.rules[FORGE.rule] or {})[FORGE.part]
|
|
|
|
if part then
|
|
entry = AUTHOR.conditions[part.item[1]] or AUTHOR.actions[part.item[1]]
|
|
end
|
|
elseif FORGE.mode == "entities" then
|
|
if FORGE.selected ~= nil then
|
|
return (name == "type") and "type" or nil
|
|
elseif group then
|
|
-- The game's form: a layer's parameter, named layer.parameter.
|
|
entry = AUTHOR.layers[group]
|
|
param = key
|
|
elseif (name == "reset") or (name == "depthSort") then
|
|
return "flag"
|
|
end
|
|
elseif FORGE.mode == "tracks" then
|
|
local track = forgeRoom().tracks[FORGE.track]
|
|
|
|
if (FORGE.key == 0) and (name == "key") then
|
|
return (track and track.spawns) and { "frame", "time", "stop" } or { "frame", "time" }
|
|
elseif (FORGE.key > 0) and (name == "type") then
|
|
return "type"
|
|
end
|
|
elseif FORGE.mode == "dialogues" then
|
|
if (name == "start") or (name == "next") then
|
|
return "node"
|
|
elseif name == "once" then
|
|
return "flag"
|
|
end
|
|
end
|
|
if entry == nil then
|
|
return nil
|
|
end
|
|
if entry.choices and entry.choices[param] then
|
|
return entry.choices[param]
|
|
end
|
|
|
|
return (entry.params or {})[param]
|
|
end
|
|
|
|
|
|
-- The list a field opens, as picker items, or nil for a value typed free: the files under the
|
|
-- game's directory, true and false, the scancodes and switches the engine names, the game's
|
|
-- rooms, types, and entities, a dialogue's nodes, a room's tracks, or the words the manifest
|
|
-- says. free says the list is a suggestion and the typed text is taken as well (a state a
|
|
-- rule sets can be any word, and a rule can name a type, a room, or an entity that is made
|
|
-- later). Every list but those that must name something offers (none):
|
|
-- the parameter left empty takes its default, or means the game itself for an entity.
|
|
function forgeFieldChoices(name)
|
|
local kind = forgeFieldKind(name)
|
|
local items = {}
|
|
local free = false
|
|
local none = true
|
|
|
|
if kind == nil then
|
|
return nil
|
|
end
|
|
local function add(word, help)
|
|
items[#items + 1] = { name = word, help = help }
|
|
end
|
|
if type(kind) == "table" then
|
|
if kind[1] == nil then
|
|
-- A manifest: the looks, or the events.
|
|
for _, word in ipairs(sortedKeys(kind)) do
|
|
add(word, kind[word].help)
|
|
end
|
|
none = false
|
|
else
|
|
for _, word in ipairs(kind) do
|
|
add(word)
|
|
end
|
|
end
|
|
elseif kind == "file" then
|
|
items = forgeFileItems()
|
|
elseif (kind == "boolean") or (kind == "flag") then
|
|
add("true")
|
|
add("false")
|
|
none = (kind == "boolean")
|
|
elseif kind == "scancode" then
|
|
for _, word in ipairs(sortedKeys(SCANCODE)) do
|
|
add(word)
|
|
end
|
|
elseif kind == "switch" then
|
|
for _, word in ipairs(sortedKeys(_G)) do
|
|
if word:match("^SWITCH_") and (type(_G[word]) == "number") then
|
|
add(word)
|
|
end
|
|
end
|
|
elseif kind == "room" then
|
|
for _, room in ipairs(FORGE.game.rooms) do
|
|
add(room.name)
|
|
end
|
|
free = true
|
|
elseif kind == "type" then
|
|
for _, word in ipairs(sortedKeys(FORGE.game.types)) do
|
|
add(word, (FORGE.game.types[word].look or {}).kind)
|
|
end
|
|
none = (FORGE.mode ~= "entities")
|
|
free = none
|
|
elseif kind == "entity" then
|
|
local seen = {}
|
|
|
|
add("self", "the instance the rule is on")
|
|
add("other", "the other one, in a collision or a trigger")
|
|
for _, room in ipairs(FORGE.game.rooms) do
|
|
for _, entry in ipairs(room.entities) do
|
|
if entry.id and not seen[entry.id] then
|
|
seen[entry.id] = true
|
|
add(entry.id, entry.type)
|
|
end
|
|
end
|
|
end
|
|
free = true
|
|
elseif kind == "state" then
|
|
local seen = {}
|
|
|
|
-- The states the game already names: a frames behaviour's ranges, an animator's clips,
|
|
-- and the states rules set or test.
|
|
for _, entityType in pairs(FORGE.game.types) do
|
|
for _, b in ipairs(entityType.behaviours or {}) do
|
|
if b.kind == "frames" then
|
|
for word in tostring(b.states or ""):gmatch("(%w+)%s*=") do
|
|
seen[word] = true
|
|
end
|
|
elseif b.kind == "animator" then
|
|
for _, word in ipairs({ "idle", "walk", "attack", "hit", "dead" }) do
|
|
if b[word] then
|
|
seen[word] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for _, rule in ipairs(FORGE.game.rules) do
|
|
for _, list in ipairs({ rule.when or {}, rule.act or {} }) do
|
|
for _, item in ipairs(list) do
|
|
if type(item.state) == "string" then
|
|
seen[item.state] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
seen.idle = true
|
|
for _, word in ipairs(sortedKeys(seen)) do
|
|
add(word)
|
|
end
|
|
free = true
|
|
elseif kind == "track" then
|
|
for _, track in ipairs(forgeRoom().tracks or {}) do
|
|
add(track.name)
|
|
end
|
|
free = true
|
|
elseif kind == "node" then
|
|
local dialogue = (FORGE.game.dialogues or {})[FORGE.dialogue or ""]
|
|
|
|
for _, word in ipairs(sortedKeys(dialogue and dialogue.nodes or {})) do
|
|
add(word)
|
|
end
|
|
none = (name ~= "start")
|
|
free = true
|
|
else
|
|
return nil
|
|
end
|
|
if none then
|
|
table.insert(items, 1, { name = FILE_NONE, help = (kind == "file") and "no file" or "left empty" })
|
|
end
|
|
|
|
return items, kind, free
|
|
end
|
|
|
|
|
|
-- ===== Keys ===================================================================================
|
|
--
|
|
-- Point the engine's callback at this and the editor is usable without a pointer at all, which is
|
|
-- how the bundled menu has always worked and what a cabinet needs.
|
|
--
|
|
-- TAB entities, types, rules, tracks, dialogues
|
|
-- UP / DOWN move within the list
|
|
-- LEFT/RIGHT move the panel out of the way
|
|
-- PGUP / PGDN the previous or next room
|
|
-- ENTER type a value for what is selected; again for its next value
|
|
-- ESC put the value back; otherwise close the description
|
|
-- A / D add, duplicate (an entity, or a type) DELETE delete what is selected
|
|
-- N new rule, new track, or (in the entities) new room
|
|
-- E pick what the rule is on C / O / T add a condition / an "any" condition / an action
|
|
-- [ / ] move the rule; in the tracks, leap the cursor
|
|
-- , / . step the cursor K a key at the cursor
|
|
-- U / R undo / redo S save
|
|
-- B build P play (Forge as a game only)
|
|
-- J / L, Y / H, I / K in a 3D room: orbit, tilt, and zoom the editor camera
|
|
-- V draw a walk area, or the selected hotspot's outline, corner by corner
|
|
-- In a picker, typing narrows the list; a field that names a file offers the game's files.
|
|
-- In the dialogues: A adds a dialogue, a node, or a choice at the level selected; T adds an
|
|
-- action to a choice.
|
|
function forgeKey(keysym, scancode)
|
|
local rules = FORGE.game.rules
|
|
local room = forgeRoom()
|
|
|
|
-- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain
|
|
-- integers, so the comparison is against .value.
|
|
if forgePickKey(scancode, keysym) then
|
|
return
|
|
end
|
|
if FORGE.drawing then
|
|
if (scancode == SCANCODE.V.value) or (scancode == SCANCODE.RETURN.value) then
|
|
forgePolygonClose()
|
|
elseif scancode == SCANCODE.ESCAPE.value then
|
|
FORGE.drawing = nil
|
|
forgePanelShow(true)
|
|
FORGE.message = "polygon dropped"
|
|
forgeRefresh()
|
|
end
|
|
return
|
|
end
|
|
if FORGE.editing ~= nil then
|
|
if scancode == SCANCODE.RETURN.value then
|
|
forgeEditNext()
|
|
return
|
|
end
|
|
if scancode == SCANCODE.ESCAPE.value then
|
|
forgeEditCancel()
|
|
return
|
|
end
|
|
-- The caret's keys. DELETE on a value just entered, still selected whole, empties it:
|
|
-- an action's entity cleared is the game itself.
|
|
if scancode == SCANCODE.DELETE.value then
|
|
forgeEditDelete()
|
|
return
|
|
elseif scancode == SCANCODE.LEFT.value then
|
|
forgeEditCaret(FORGE.editing.selected and #FORGE.editing.text or (FORGE.editing.cursor - 1))
|
|
return
|
|
elseif scancode == SCANCODE.RIGHT.value then
|
|
forgeEditCaret(FORGE.editing.selected and #FORGE.editing.text or (FORGE.editing.cursor + 1))
|
|
return
|
|
elseif scancode == SCANCODE.HOME.value then
|
|
forgeEditCaret(0)
|
|
return
|
|
elseif scancode == SCANCODE.END.value then
|
|
forgeEditCaret(#FORGE.editing.text)
|
|
return
|
|
end
|
|
if forgeTyped(keysym) then
|
|
return
|
|
end
|
|
elseif scancode == SCANCODE.RETURN.value then
|
|
forgeEditNext()
|
|
return
|
|
end
|
|
if scancode ~= SCANCODE.ESCAPE.value then
|
|
FORGE.closing = false
|
|
end
|
|
|
|
if scancode == SCANCODE.TAB.value then
|
|
for at, mode in ipairs(MODES) do
|
|
if mode == FORGE.mode then
|
|
forgeMode(MODES[(at % #MODES) + 1])
|
|
return
|
|
end
|
|
end
|
|
forgeMode("entities")
|
|
|
|
-- The digits go straight to a panel, so types to entities is one key and not four.
|
|
elseif (scancode >= SCANCODE.MAIN_1.value) and (scancode <= SCANCODE.MAIN_5.value) then
|
|
forgeMode(MODES[scancode - SCANCODE.MAIN_1.value + 1])
|
|
|
|
elseif scancode == SCANCODE.LEFT.value then
|
|
forgePanelTo(FORGE.panelX - 40)
|
|
elseif scancode == SCANCODE.RIGHT.value then
|
|
forgePanelTo(FORGE.panelX + 40)
|
|
elseif scancode == SCANCODE.PAGEUP.value then
|
|
forgeRoomGo(FORGE.room - 1)
|
|
elseif scancode == SCANCODE.PAGEDOWN.value then
|
|
forgeRoomGo(FORGE.room + 1)
|
|
|
|
-- The editor camera, in a 3D room: J and L orbit, Y and H tilt, I and K close in and back off.
|
|
elseif FORGE.scene and (scancode == SCANCODE.J.value) then
|
|
forgeOrbit(-ORBIT_STEP, 0, 1)
|
|
elseif FORGE.scene and (scancode == SCANCODE.L.value) then
|
|
forgeOrbit(ORBIT_STEP, 0, 1)
|
|
elseif FORGE.scene and (scancode == SCANCODE.Y.value) then
|
|
forgeOrbit(0, ORBIT_STEP, 1)
|
|
elseif FORGE.scene and (scancode == SCANCODE.H.value) then
|
|
forgeOrbit(0, -ORBIT_STEP, 1)
|
|
elseif FORGE.scene and (scancode == SCANCODE.I.value) then
|
|
forgeOrbit(0, 0, 1 / ZOOM_STEP)
|
|
elseif FORGE.scene and (scancode == SCANCODE.K.value) and (FORGE.mode ~= "tracks") then
|
|
forgeOrbit(0, 0, ZOOM_STEP)
|
|
|
|
elseif FORGE.mode == "entities" then
|
|
if scancode == SCANCODE.DOWN.value then
|
|
forgeSelect(math.min((FORGE.selected or 0) + 1, #room.entities))
|
|
elseif scancode == SCANCODE.UP.value then
|
|
forgeSelect(math.max((FORGE.selected or 2) - 1, 1))
|
|
elseif scancode == SCANCODE.A.value then
|
|
forgeEntityAdd()
|
|
elseif scancode == SCANCODE.D.value then
|
|
forgeEntityDuplicate()
|
|
elseif scancode == SCANCODE.DELETE.value then
|
|
forgeEntityDelete()
|
|
elseif scancode == SCANCODE.N.value then
|
|
forgeRoomAdd()
|
|
elseif (scancode == SCANCODE.V.value) and not FORGE.scene then
|
|
forgePolygonBegin()
|
|
end
|
|
|
|
elseif FORGE.mode == "types" then
|
|
local names = sortedKeys(FORGE.game.types)
|
|
local at = 0
|
|
|
|
for index, name in ipairs(names) do
|
|
if name == FORGE.typeName then
|
|
at = index
|
|
end
|
|
end
|
|
if scancode == SCANCODE.DOWN.value then
|
|
FORGE.typeName = names[math.min(at + 1, #names)] or FORGE.typeName
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif scancode == SCANCODE.UP.value then
|
|
FORGE.typeName = names[math.max(at - 1, 1)] or FORGE.typeName
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif scancode == SCANCODE.A.value then
|
|
forgeTypeAdd()
|
|
elseif scancode == SCANCODE.D.value then
|
|
forgeTypeDuplicate()
|
|
elseif scancode == SCANCODE.DELETE.value then
|
|
forgeTypeDelete()
|
|
end
|
|
|
|
elseif FORGE.mode == "rules" then
|
|
-- In the rules, up and down walk the rule and its parts as one list: past the last part
|
|
-- of a rule is the next rule, which is how it reads on screen.
|
|
if scancode == SCANCODE.DOWN.value then
|
|
local parts = forgeParts(rules[FORGE.rule] or {})
|
|
|
|
if FORGE.part < #parts then
|
|
FORGE.part = FORGE.part + 1
|
|
elseif FORGE.rule < #rules then
|
|
FORGE.rule = FORGE.rule + 1
|
|
FORGE.part = 0
|
|
end
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif scancode == SCANCODE.UP.value then
|
|
if FORGE.part > 0 then
|
|
FORGE.part = FORGE.part - 1
|
|
elseif FORGE.rule > 1 then
|
|
FORGE.rule = FORGE.rule - 1
|
|
FORGE.part = #forgeParts(rules[FORGE.rule] or {})
|
|
end
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif scancode == SCANCODE.N.value then
|
|
forgeRuleNew()
|
|
elseif scancode == SCANCODE.E.value then
|
|
if rules[FORGE.rule] ~= nil then
|
|
forgePickBegin("what the rule is on", AUTHOR.events, forgeRuleOn)
|
|
end
|
|
elseif scancode == SCANCODE.C.value then
|
|
if rules[FORGE.rule] ~= nil then
|
|
forgePickBegin("a condition", AUTHOR.conditions, function(name) forgeRuleAdd("when", name) end)
|
|
end
|
|
elseif scancode == SCANCODE.O.value then
|
|
if rules[FORGE.rule] ~= nil then
|
|
forgePickBegin("a condition, any of which", AUTHOR.conditions, function(name) forgeRuleAdd("any", name) end)
|
|
end
|
|
elseif scancode == SCANCODE.T.value then
|
|
if rules[FORGE.rule] ~= nil then
|
|
forgePickBegin("an action", AUTHOR.actions, function(name) forgeRuleAdd("act", name) end)
|
|
end
|
|
elseif scancode == SCANCODE.LEFTBRACKET.value then
|
|
forgeRuleMove(-1)
|
|
elseif scancode == SCANCODE.RIGHTBRACKET.value then
|
|
forgeRuleMove(1)
|
|
elseif scancode == SCANCODE.DELETE.value then
|
|
if FORGE.part > 0 then
|
|
forgePartDelete()
|
|
else
|
|
forgeRuleDelete()
|
|
end
|
|
end
|
|
|
|
elseif FORGE.mode == "dialogues" then
|
|
if scancode == SCANCODE.DOWN.value then
|
|
forgeDialogueStep(1)
|
|
elseif scancode == SCANCODE.UP.value then
|
|
forgeDialogueStep(-1)
|
|
elseif scancode == SCANCODE.A.value then
|
|
forgeDialogueAdd()
|
|
elseif scancode == SCANCODE.T.value then
|
|
forgePickBegin("an action for the choice", AUTHOR.actions, forgeDialogueAct)
|
|
elseif scancode == SCANCODE.DELETE.value then
|
|
forgeDialogueDelete()
|
|
end
|
|
|
|
elseif FORGE.mode == "tracks" then
|
|
local track = room.tracks[FORGE.track]
|
|
local keys = track and forgeTrackKeys(track) or {}
|
|
|
|
if scancode == SCANCODE.DOWN.value then
|
|
if track and (FORGE.key < #keys) then
|
|
FORGE.key = FORGE.key + 1
|
|
forgeCursorTo(keys[FORGE.key].at)
|
|
elseif FORGE.track < #room.tracks then
|
|
FORGE.track = FORGE.track + 1
|
|
FORGE.key = 0
|
|
end
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif scancode == SCANCODE.UP.value then
|
|
if FORGE.key > 0 then
|
|
FORGE.key = FORGE.key - 1
|
|
if FORGE.key > 0 then
|
|
forgeCursorTo(keys[FORGE.key].at)
|
|
end
|
|
elseif FORGE.track > 1 then
|
|
FORGE.track = FORGE.track - 1
|
|
FORGE.key = #forgeTrackKeys(room.tracks[FORGE.track])
|
|
end
|
|
FORGE.lastField = nil
|
|
forgeRefresh()
|
|
elseif scancode == SCANCODE.N.value then
|
|
forgeTrackNew()
|
|
elseif scancode == SCANCODE.W.value then
|
|
forgeTrackNew(nil, true)
|
|
elseif scancode == SCANCODE.K.value then
|
|
forgeKeyAdd()
|
|
elseif scancode == SCANCODE.COMMA.value then
|
|
forgeCursorTo(FORGE.cursor - FRAME_STEP)
|
|
elseif scancode == SCANCODE.PERIOD.value then
|
|
forgeCursorTo(FORGE.cursor + FRAME_STEP)
|
|
elseif scancode == SCANCODE.LEFTBRACKET.value then
|
|
forgeCursorTo(FORGE.cursor - FRAME_LEAP)
|
|
elseif scancode == SCANCODE.RIGHTBRACKET.value then
|
|
forgeCursorTo(FORGE.cursor + FRAME_LEAP)
|
|
elseif scancode == SCANCODE.DELETE.value then
|
|
if FORGE.key > 0 then
|
|
forgeKeyDelete()
|
|
else
|
|
forgeTrackDelete()
|
|
end
|
|
end
|
|
end
|
|
|
|
if scancode == SCANCODE.S.value then
|
|
forgeSave()
|
|
elseif scancode == SCANCODE.B.value then
|
|
forgeBuild(singeGetDataPath() .. "preview.singe")
|
|
elseif scancode == SCANCODE.X.value then
|
|
forgeExport(forgeReleaseFolder())
|
|
elseif scancode == SCANCODE.U.value then
|
|
forgeUndo()
|
|
elseif scancode == SCANCODE.R.value then
|
|
forgeRedo()
|
|
end
|
|
end
|
|
|
|
|
|
-- ===== Typing a value =========================================================================
|
|
--
|
|
-- A rule editor that can add "when keyHeld" but cannot change SPACE to UP is most of the way to
|
|
-- useless, which is the half of the job that matters for someone with little programming skill.
|
|
--
|
|
-- Keysyms arrive as characters the way Tools.singe takes them, so this needs nothing of the GUI:
|
|
-- ENTER starts editing whatever is selected, each press moves to its next field, and ESCAPE puts
|
|
-- back what was there. An entity, a type, a rule, a key, and a condition are typed the same way;
|
|
-- forgeFields says what the fields are and how to reach them.
|
|
|
|
-- The parameters of a condition or action, in the order the panel shows them, so typing walks them
|
|
-- the same way.
|
|
-- A part's fields: every parameter its condition or action takes, whether set yet or not, and
|
|
-- anything else the description gave it.
|
|
function forgeFieldNames(item)
|
|
local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] or {}
|
|
local seen = {}
|
|
local keys = {}
|
|
|
|
for key in pairs(entry.params or {}) do
|
|
seen[key] = true
|
|
keys[#keys + 1] = key
|
|
end
|
|
for key in pairs(item) do
|
|
if (key ~= 1) and not seen[key] then
|
|
keys[#keys + 1] = key
|
|
end
|
|
end
|
|
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
|
|
|
return keys
|
|
end
|
|
|
|
|
|
-- What ENTER edits: the fields of whatever is selected, with a way to read and write each. nil
|
|
-- when nothing is.
|
|
function forgeFields()
|
|
local room = forgeRoom()
|
|
|
|
if FORGE.mode == "rules" then
|
|
local rule = FORGE.game.rules[FORGE.rule]
|
|
local part
|
|
|
|
if rule == nil then
|
|
return nil
|
|
end
|
|
if FORGE.part == 0 then
|
|
return {
|
|
names = forgeRuleFieldNames(rule),
|
|
get = function(key)
|
|
if key == "controls" then
|
|
return rule.controls ~= false
|
|
elseif key == "interrupt" then
|
|
return rule.interrupt == true
|
|
end
|
|
return rule[key]
|
|
end,
|
|
set = function(key, value)
|
|
if key == "on" then
|
|
if AUTHOR.events[value] then
|
|
forgeRuleOn(value)
|
|
end
|
|
return
|
|
elseif key == "controls" then
|
|
rule.controls = (value ~= false) and nil or false
|
|
elseif key == "interrupt" then
|
|
rule.interrupt = (value == true) or nil
|
|
elseif (key == "each" or key == "room") and (value == "") then
|
|
rule[key] = nil
|
|
else
|
|
rule[key] = value
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
part = forgeParts(rule)[FORGE.part]
|
|
if part == nil then
|
|
return nil
|
|
end
|
|
return {
|
|
names = forgeFieldNames(part.item),
|
|
get = function(key) return part.item[key] end,
|
|
set = forgePartSet
|
|
}
|
|
elseif FORGE.mode == "types" then
|
|
local entityType = FORGE.game.types[FORGE.typeName or ""]
|
|
|
|
if entityType == nil then
|
|
return nil
|
|
end
|
|
return {
|
|
names = forgeTypeFieldNames(entityType),
|
|
get = function(key) return forgeTypeGet(entityType, key) end,
|
|
set = function(key, value) forgeTypeSet(entityType, key, value) end
|
|
}
|
|
elseif FORGE.mode == "dialogues" then
|
|
return forgeDialogueFields()
|
|
elseif FORGE.mode == "tracks" then
|
|
local track = room.tracks[FORGE.track]
|
|
|
|
if track == nil then
|
|
return nil
|
|
end
|
|
if FORGE.key == 0 then
|
|
return {
|
|
names = { "name", "key" },
|
|
get = function(key) return track[key] end,
|
|
set = function(key, value)
|
|
if (key ~= "key") or (value == "frame") or (value == "time") or ((value == "stop") and track.spawns) then
|
|
track[key] = value
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
local keys = forgeTrackKeys(track)
|
|
local box = keys[FORGE.key]
|
|
|
|
return {
|
|
names = track.spawns and { "at", "type", "count", "every", "from", "x", "y", "z" } or (track.points and { "at", "x", "y", "z", "lookX", "lookY", "lookZ", "stop" } or { "at", "x", "y", "w", "h" }),
|
|
get = function(key)
|
|
if key:sub(1, 4) == "look" then
|
|
local look = box.look or {}
|
|
local axis = key:sub(5):lower()
|
|
|
|
return look[axis] or look[({ x = 1, y = 2, z = 3 })[axis]]
|
|
end
|
|
return box[key]
|
|
end,
|
|
set = function(key, value)
|
|
if key:sub(1, 4) == "look" then
|
|
local axis = key:sub(5):lower()
|
|
|
|
if type(value) == "number" then
|
|
box.look = box.look or { 0, 0, 0 }
|
|
if box.look.x ~= nil then
|
|
box.look[axis] = value
|
|
else
|
|
box.look[({ x = 1, y = 2, z = 3 })[axis]] = value
|
|
end
|
|
end
|
|
elseif key == "stop" then
|
|
box.stop = (value ~= "") and tostring(value) or nil
|
|
elseif track.spawns and ((key == "type") or (key == "from") or ((key == "at") and (track.key == "stop"))) then
|
|
-- A wave's words: its type, where it comes from, and the stop it waits for.
|
|
box[key] = ((value ~= nil) and (value ~= "")) and tostring(value) or nil
|
|
if key == "at" then
|
|
table.sort(keys, forgeKeyOrder)
|
|
for at, item in ipairs(keys) do
|
|
if item == box then
|
|
FORGE.key = at
|
|
end
|
|
end
|
|
end
|
|
elseif type(value) == "number" then
|
|
box[key] = value
|
|
if key == "at" then
|
|
table.sort(keys, forgeKeyOrder)
|
|
for at, item in ipairs(keys) do
|
|
if item == box then
|
|
FORGE.key = at
|
|
end
|
|
end
|
|
end
|
|
end
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
}
|
|
end
|
|
local entry = room.entities[FORGE.selected]
|
|
|
|
if entry == nil then
|
|
-- Nothing selected: the game, then the room itself.
|
|
return {
|
|
names = forgeRoomFieldNames(),
|
|
get = function(key) return forgeRoomGet(room, key) end,
|
|
set = function(key, value) forgeRoomSet(room, key, value) end
|
|
}
|
|
end
|
|
|
|
return {
|
|
names = forgeEntityFieldNames(entry),
|
|
get = function(key) return forgeEntityGet(entry, key) end,
|
|
set = function(key, value) forgeEntitySet(entry, key, value) end
|
|
}
|
|
end
|
|
|
|
|
|
-- A room's own fields, typed as text: walk areas as polygons of x,y pairs separated by
|
|
-- semicolons, navFrom as a list of entity ids, scaleBy as y:scale pairs.
|
|
-- The fields with nothing selected: the game's -- its title, players, verbs, and layers with
|
|
-- what each layer takes, named layer.parameter as a behaviour's are -- and then the room's.
|
|
function forgeRoomFieldNames()
|
|
local names = { "title", "players", "vars", "verbs", "layers" }
|
|
|
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
|
local entry = AUTHOR.layers[layer.kind]
|
|
|
|
for _, key in ipairs(sortedKeys(entry and entry.params or layer)) do
|
|
if key ~= "kind" then
|
|
names[#names + 1] = layer.kind .. "." .. key
|
|
end
|
|
end
|
|
end
|
|
for _, key in ipairs({ "name", "reset", "depthSort", "walk", "navFrom", "scaleBy" }) do
|
|
names[#names + 1] = key
|
|
end
|
|
|
|
return names
|
|
end
|
|
|
|
|
|
local function layerOf(name)
|
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
|
if layer.kind == name then
|
|
return layer
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
function forgeRoomGet(room, key)
|
|
local group, field = string.match(key, "^([^.]+)%.(.+)$")
|
|
|
|
if key == "title" then
|
|
return FORGE.game.title
|
|
elseif key == "players" then
|
|
return FORGE.game.players or 1
|
|
elseif key == "vars" then
|
|
return varsText(FORGE.game.vars)
|
|
elseif key == "verbs" then
|
|
return table.concat(FORGE.game.verbs or {}, ", ")
|
|
elseif key == "layers" then
|
|
return listText(FORGE.game.layers)
|
|
elseif group ~= nil then
|
|
local layer = layerOf(group)
|
|
|
|
return layer and tableText(layer[field]) or nil
|
|
elseif key == "walk" then
|
|
local polygons = {}
|
|
|
|
for _, polygon in ipairs(room.walk or {}) do
|
|
local pairs_ = {}
|
|
|
|
for at = 1, #polygon - 1, 2 do
|
|
pairs_[#pairs_ + 1] = polygon[at] .. "," .. polygon[at + 1]
|
|
end
|
|
polygons[#polygons + 1] = table.concat(pairs_, " ")
|
|
end
|
|
return table.concat(polygons, "; ")
|
|
elseif key == "navFrom" then
|
|
return table.concat(room.navFrom or {}, ", ")
|
|
elseif key == "scaleBy" then
|
|
local parts = {}
|
|
|
|
for _, item in ipairs(room.scaleBy or {}) do
|
|
parts[#parts + 1] = item.y .. ":" .. item.scale
|
|
end
|
|
return table.concat(parts, ", ")
|
|
elseif key == "reset" or key == "depthSort" then
|
|
return room[key] == true
|
|
end
|
|
|
|
return room[key]
|
|
end
|
|
|
|
|
|
function forgeRoomSet(room, key, value)
|
|
local group, field = string.match(key, "^([^.]+)%.(.+)$")
|
|
|
|
if key == "title" then
|
|
FORGE.game.title = tostring(value)
|
|
elseif key == "players" then
|
|
if type(value) == "number" then
|
|
FORGE.game.players = (value ~= 1) and math.floor(value) or nil
|
|
end
|
|
elseif key == "vars" then
|
|
FORGE.game.vars = varsFrom(value)
|
|
elseif key == "verbs" then
|
|
local verbs = {}
|
|
|
|
for verb in string.gmatch(tostring(value), "[^,%s]+") do
|
|
verbs[#verbs + 1] = verb
|
|
end
|
|
FORGE.game.verbs = (#verbs > 0) and verbs or nil
|
|
elseif key == "layers" then
|
|
FORGE.stash.layers = FORGE.stash.layers or {}
|
|
FORGE.game.layers = listFrom(value, FORGE.game.layers, AUTHOR.layers, FORGE.stash.layers, "layer")
|
|
-- A scene3d layer typed in or out turns the canvas into the viewport, or back.
|
|
if forgeIs3D() then
|
|
if FORGE.scene == nil then
|
|
forgeSceneBegin()
|
|
end
|
|
elseif FORGE.scene ~= nil then
|
|
forgeSceneClear()
|
|
end
|
|
elseif group ~= nil then
|
|
local layer = layerOf(group)
|
|
|
|
if layer ~= nil then
|
|
layer[field] = valueFor((AUTHOR.layers[group] or {}).params, field, value)
|
|
end
|
|
elseif key == "walk" then
|
|
local polygons = {}
|
|
|
|
for text in string.gmatch(tostring(value), "[^;]+") do
|
|
local polygon = {}
|
|
|
|
for number in string.gmatch(text, "-?[%d.]+") do
|
|
polygon[#polygon + 1] = tonumber(number)
|
|
end
|
|
if #polygon >= 6 then
|
|
polygons[#polygons + 1] = polygon
|
|
end
|
|
end
|
|
room.walk = (#polygons > 0) and polygons or nil
|
|
elseif key == "navFrom" then
|
|
local ids = {}
|
|
|
|
for id in string.gmatch(tostring(value), "[^,%s]+") do
|
|
ids[#ids + 1] = id
|
|
end
|
|
room.navFrom = (#ids > 0) and ids or nil
|
|
elseif key == "scaleBy" then
|
|
local list = {}
|
|
|
|
for y, scale in string.gmatch(tostring(value), "(-?[%d.]+)%s*:%s*(-?[%d.]+)") do
|
|
list[#list + 1] = { y = tonumber(y), scale = tonumber(scale) }
|
|
end
|
|
room.scaleBy = (#list > 0) and list or nil
|
|
elseif key == "reset" or key == "depthSort" then
|
|
room[key] = (value == true) or nil
|
|
elseif key == "name" then
|
|
if (value ~= "") then
|
|
room.name = tostring(value)
|
|
end
|
|
end
|
|
-- A disc named or renamed is what the canvas shows from now on.
|
|
loadVideo()
|
|
FORGE.dirty = true
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- A typed value as the description should hold it. A number has to come back a number: "220" and
|
|
-- 220 compile to different source, and a description that changed shape because a value was
|
|
-- retyped would stop round-tripping.
|
|
local function typedValue(text, was)
|
|
local number = tonumber(text)
|
|
|
|
if (type(was) == "number") and (number ~= nil) then
|
|
return number
|
|
end
|
|
if text == "true" then
|
|
return true
|
|
end
|
|
if text == "false" then
|
|
return false
|
|
end
|
|
if (type(was) ~= "string") and (number ~= nil) then
|
|
return number
|
|
end
|
|
|
|
return text
|
|
end
|
|
|
|
|
|
-- What is being typed: the field's value as text, whole and selected to begin with, so typing
|
|
-- replaces it and the arrows, Home, and End move a caret into it to edit it instead. changed
|
|
-- says whether anything was done to it, so a field passed over keeps its value.
|
|
function forgeEditingOf(key, was, set)
|
|
local text = (was ~= nil) and tostring(was) or ""
|
|
|
|
return { key = key, text = text, cursor = #text, selected = (text ~= ""), changed = false, was = was, set = set }
|
|
end
|
|
|
|
|
|
-- What the text being typed is worth: nothing when it is empty, and false is a value.
|
|
function forgeEditValue()
|
|
if FORGE.editing.text == "" then
|
|
return nil
|
|
end
|
|
|
|
return typedValue(FORGE.editing.text, FORGE.editing.was)
|
|
end
|
|
|
|
|
|
function forgeEditCommit()
|
|
if FORGE.editing == nil then
|
|
return false
|
|
end
|
|
-- A field passed over with nothing done to it keeps its value: ENTER, ENTER, ENTER walks the
|
|
-- form without touching it. One emptied is set to nothing.
|
|
if FORGE.editing.changed then
|
|
FORGE.editing.set(FORGE.editing.key, forgeEditValue())
|
|
end
|
|
FORGE.editing = nil
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function forgeEditCancel()
|
|
if FORGE.editing == nil then
|
|
return false
|
|
end
|
|
FORGE.editing.set(FORGE.editing.key, FORGE.editing.was)
|
|
FORGE.editing = nil
|
|
FORGE.lastField = nil -- The next ENTER starts the form over rather than moving on.
|
|
FORGE.message = "unchanged"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Starts editing, or moves on to the next field of the same thing. Committing as it moves is
|
|
-- what makes ENTER, ENTER, ENTER feel like filling in a form.
|
|
function forgeEditNext()
|
|
local fields = forgeFields()
|
|
local at = 0
|
|
|
|
if fields == nil then
|
|
return false
|
|
end
|
|
if #fields.names == 0 then
|
|
FORGE.message = "nothing to type here"
|
|
forgeRefresh()
|
|
return false
|
|
end
|
|
if FORGE.editing ~= nil then
|
|
forgeEditCommit()
|
|
else
|
|
-- One undo step for the whole form, taken when typing starts.
|
|
forgeRemember()
|
|
end
|
|
for i = 1, #fields.names do
|
|
if fields.names[i] == FORGE.lastField then
|
|
at = i
|
|
end
|
|
end
|
|
at = (at % #fields.names) + 1
|
|
FORGE.lastField = fields.names[at]
|
|
FORGE.editing = forgeEditingOf(fields.names[at], fields.get(fields.names[at]), fields.set)
|
|
-- A field with a fixed set of values -- a file, a flag, a key, a type, a look -- offers
|
|
-- them as a list, opened on the value it has (or on (none) when it has none) so ENTER
|
|
-- straight away keeps things as they are and the form can be walked through without
|
|
-- changing it. ESC on the list leaves the field being typed instead.
|
|
if forgeFieldList(fields, fields.names[at]) then
|
|
return true
|
|
end
|
|
FORGE.message = "type a value for " .. fields.names[at] .. ", ENTER for the next, ESC to put it back"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- One typed character. Point the engine's onKeyPressed at forgeKey and this is reached from there.
|
|
function forgeTyped(keysym)
|
|
if FORGE.editing == nil then
|
|
return false
|
|
end
|
|
if keysym == 8 then
|
|
forgeEditBackspace()
|
|
elseif (keysym >= 32) and (keysym < 127) and not FORGE.textInput then
|
|
-- A keysym is the unshifted key. With the engine delivering typed text (onTextInput),
|
|
-- the characters come through forgeText instead, shifted as the person typed them.
|
|
forgeEditInsert(string.char(keysym))
|
|
else
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Puts text in at the caret, or in place of the selected value.
|
|
function forgeEditInsert(text)
|
|
local editing = FORGE.editing
|
|
|
|
if editing.selected then
|
|
editing.text = text
|
|
editing.cursor = #text
|
|
editing.selected = false
|
|
else
|
|
editing.text = editing.text:sub(1, editing.cursor) .. text .. editing.text:sub(editing.cursor + 1)
|
|
editing.cursor = editing.cursor + #text
|
|
end
|
|
editing.changed = true
|
|
forgeTypedShow()
|
|
end
|
|
|
|
|
|
-- Takes out the character before the caret, or the whole selected value.
|
|
function forgeEditBackspace()
|
|
local editing = FORGE.editing
|
|
|
|
if editing.selected then
|
|
editing.text = ""
|
|
editing.cursor = 0
|
|
editing.selected = false
|
|
elseif editing.cursor > 0 then
|
|
editing.text = editing.text:sub(1, editing.cursor - 1) .. editing.text:sub(editing.cursor + 1)
|
|
editing.cursor = editing.cursor - 1
|
|
else
|
|
return
|
|
end
|
|
editing.changed = true
|
|
forgeTypedShow()
|
|
end
|
|
|
|
|
|
-- Takes out the character after the caret, or the whole selected value: DELETE on a field just
|
|
-- entered empties it.
|
|
function forgeEditDelete()
|
|
local editing = FORGE.editing
|
|
|
|
if editing.selected then
|
|
editing.text = ""
|
|
editing.cursor = 0
|
|
editing.selected = false
|
|
editing.was = nil -- Emptied for good: ESC leaves it empty.
|
|
FORGE.message = editing.key .. " emptied"
|
|
elseif editing.cursor < #editing.text then
|
|
editing.text = editing.text:sub(1, editing.cursor) .. editing.text:sub(editing.cursor + 2)
|
|
else
|
|
return
|
|
end
|
|
editing.changed = true
|
|
forgeTypedShow()
|
|
end
|
|
|
|
|
|
-- Moves the caret: by one, or to an end. A selected value is dropped into a caret first.
|
|
function forgeEditCaret(to)
|
|
local editing = FORGE.editing
|
|
|
|
editing.selected = false
|
|
editing.cursor = math.max(0, math.min(#editing.text, to))
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- Typed text, as the engine delivers it in full keyboard mode: what the keys produced with
|
|
-- their shifts and layout, so a capital or a quote is what was typed. Goes to the picker's filter
|
|
-- while a list is up, and to the field being edited otherwise.
|
|
function forgeText(text)
|
|
if FORGE.picker ~= nil then
|
|
FORGE.picker.filter = FORGE.picker.filter .. text
|
|
FORGE.picker.at = 1
|
|
forgeRefresh()
|
|
return true
|
|
end
|
|
if FORGE.editing == nil then
|
|
return false
|
|
end
|
|
forgeEditInsert(text)
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Shows the field as it is being typed: the value in the list is the value being entered.
|
|
function forgeTypedShow()
|
|
FORGE.editing.set(FORGE.editing.key, forgeEditValue())
|
|
FORGE.message = "typing " .. FORGE.editing.key .. ": ENTER for the next, ESC to put it back"
|
|
forgeRefresh()
|
|
end
|
|
|
|
|
|
-- ===== Files ==================================================================================
|
|
--
|
|
-- Descriptions live in Forge's data directory, which is the one place a packed Forge can write.
|
|
-- Ones dropped into Forge's own directory are offered too, and opened as a copy in the data
|
|
-- directory, since inside a .game they are read only.
|
|
|
|
-- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play
|
|
-- on, which is the point; everything in it can be changed or deleted.
|
|
function forgeTemplate()
|
|
return {
|
|
title = "New game",
|
|
layers = { { kind = "world2d", gravity = 1500 } },
|
|
vars = { score = 0 },
|
|
types = {
|
|
ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } },
|
|
hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170, anchor = "feet" }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } },
|
|
readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } }
|
|
},
|
|
rooms = {
|
|
{ name = "start",
|
|
entities = {
|
|
{ type = "ground", id = "ground", x = 360, y = 440 },
|
|
{ type = "hero", id = "hero", x = 120, y = 420 },
|
|
{ type = "readout", id = "readout", x = 12, y = 12 }
|
|
},
|
|
tracks = {} }
|
|
},
|
|
rules = {
|
|
{ note = "Run left", on = "frame", when = { { "keyHeld", key = "LEFT" } }, act = { { "run", entity = "hero", direction = -1 } } },
|
|
{ note = "Run right", on = "frame", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } },
|
|
{ note = "Jump, with ground underfoot", on = "frame", when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, act = { { "jump", entity = "hero" } } },
|
|
{ note = "The readout, every frame", on = "frame", act = { { "setText", entity = "readout", text = '"score " .. score' } } }
|
|
}
|
|
}
|
|
end
|
|
|
|
|
|
-- Whether a .game file is a packed game rather than a description: the database's own header.
|
|
local function packedGame(path)
|
|
local file = io.open(path, "rb")
|
|
local head
|
|
|
|
if file == nil then
|
|
return false
|
|
end
|
|
head = file:read(15)
|
|
file:close()
|
|
|
|
return head == "SQLite format 3"
|
|
end
|
|
|
|
|
|
-- Every .game description in a directory, sorted. A directory that is not there lists nothing.
|
|
local function descriptionsIn(directory)
|
|
local found = {}
|
|
|
|
if lfs.attributes(directory, "mode") ~= "directory" then
|
|
return found
|
|
end
|
|
for name in lfs.dir(directory) do
|
|
-- A description is Lua text; a packed game is a database with the same extension, and one
|
|
-- released beside the descriptions is not offered as one.
|
|
if (string.sub(name, -5) == ".game") and not packedGame(directory .. name) then
|
|
found[#found + 1] = { name = string.sub(name, 1, -6), path = directory .. name }
|
|
end
|
|
end
|
|
table.sort(found, function(a, b) return a.name < b.name end)
|
|
|
|
return found
|
|
end
|
|
|
|
|
|
-- What the chooser offers: the data directory's descriptions, then Forge's own, then a new game.
|
|
function forgeFiles()
|
|
local files = descriptionsIn(singeGetDataPath())
|
|
|
|
for _, sample in ipairs(descriptionsIn("Forge/")) do
|
|
sample.sample = true
|
|
files[#files + 1] = sample
|
|
end
|
|
for _, tutorial in ipairs(descriptionsIn("Forge/tutorials/")) do
|
|
tutorial.sample = true
|
|
tutorial.tutorial = true
|
|
files[#files + 1] = tutorial
|
|
end
|
|
files[#files + 1] = { name = "New game", fresh = true }
|
|
|
|
return files
|
|
end
|
|
|
|
|
|
-- A path in the data directory no description has yet.
|
|
local function freshPath(stem)
|
|
local base = singeGetDataPath() .. stem
|
|
local n = 1
|
|
|
|
if lfs.attributes(base .. ".game") == nil then
|
|
return base .. ".game"
|
|
end
|
|
while lfs.attributes(base .. n .. ".game") ~= nil do
|
|
n = n + 1
|
|
end
|
|
|
|
return base .. n .. ".game"
|
|
end
|
|
|
|
|
|
-- Opens an entry from forgeFiles. A sample is copied first; a new game is written first.
|
|
function forgeOpen(entry)
|
|
local path = entry.path
|
|
|
|
if entry.fresh then
|
|
path = freshPath("NewGame")
|
|
authorSave(forgeTemplate(), path)
|
|
elseif entry.sample then
|
|
path = freshPath(entry.name)
|
|
if not authorCopy(entry.path, path) then
|
|
return false
|
|
end
|
|
end
|
|
if not forgeBegin(path) then
|
|
return false
|
|
end
|
|
if entry.sample then
|
|
FORGE.message = "opened a copy: " .. path
|
|
elseif entry.fresh then
|
|
FORGE.message = "new game at " .. path
|
|
end
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- ===== The chooser ============================================================================
|
|
--
|
|
-- What Forge opens on: the descriptions in its data directory, the samples and tutorials inside
|
|
-- Forge.game, and a new game. Part of the library so a scene can draw it and press its keys.
|
|
|
|
local MANUAL = singeGetDataPath() .. "Forge.pdf" -- The manual, put where a person can open it.
|
|
local MANUAL_SHOWN = 64 -- Characters of its path the chooser has room for.
|
|
local ROW_H = 22
|
|
local LIST_X = 60
|
|
local LIST_Y = 80
|
|
|
|
|
|
function forgeChooserScan()
|
|
FORGE.chooser.files = forgeFiles()
|
|
FORGE.chooser.at = math.max(1, math.min(FORGE.chooser.at, #FORGE.chooser.files))
|
|
end
|
|
|
|
|
|
-- The manual travels inside Forge.game, where nothing but Singe can read it. The first run
|
|
-- copies it out beside the descriptions, and the chooser says where.
|
|
function forgeChooserManual()
|
|
if (lfs.attributes(MANUAL, "mode") ~= "file") and (lfs.attributes("Forge/Forge.pdf", "mode") == "file") then
|
|
authorCopy("Forge/Forge.pdf", MANUAL)
|
|
end
|
|
|
|
return (lfs.attributes(MANUAL, "mode") == "file") and MANUAL or nil
|
|
end
|
|
|
|
|
|
function forgeChooserDraw()
|
|
local y = LIST_Y
|
|
|
|
colorBackground(18, 18, 26, 255)
|
|
overlayClear()
|
|
colorForeground(255, 207, 74, 255)
|
|
fontPrint(LIST_X, 30, "Forge")
|
|
colorForeground(154, 160, 180, 255)
|
|
fontPrint(LIST_X + 80, 30, "UP / DOWN choose ENTER open ESC leave")
|
|
if FORGE.chooser.manual ~= nil then
|
|
local shown = FORGE.chooser.manual
|
|
|
|
-- A data directory given on the command line can be anywhere; the end of the path is
|
|
-- the part that says where the file is.
|
|
if #shown > MANUAL_SHOWN then
|
|
shown = "..." .. string.sub(shown, -MANUAL_SHOWN)
|
|
end
|
|
fontPrint(LIST_X, overlayGetHeight() - 40, "Manual: " .. shown)
|
|
end
|
|
for at, entry in ipairs(FORGE.chooser.files) do
|
|
local label = entry.name
|
|
|
|
if entry.tutorial then
|
|
label = "tutorial " .. label .. " (a copy will be made)"
|
|
elseif entry.sample then
|
|
label = label .. " (a copy will be made)"
|
|
end
|
|
if at == FORGE.chooser.at then
|
|
colorForeground(192, 60, 150, 255)
|
|
fill(LIST_X - 10, y - 3, 410, ROW_H - 3)
|
|
colorForeground(255, 255, 255, 255)
|
|
else
|
|
colorForeground(230, 230, 238, 255)
|
|
end
|
|
fontPrint(LIST_X, y, label)
|
|
y = y + ROW_H
|
|
end
|
|
end
|
|
|
|
|
|
function forgeChooserKey(scancode)
|
|
if scancode == SCANCODE.DOWN.value then
|
|
FORGE.chooser.at = math.min(FORGE.chooser.at + 1, #FORGE.chooser.files)
|
|
elseif scancode == SCANCODE.UP.value then
|
|
FORGE.chooser.at = math.max(FORGE.chooser.at - 1, 1)
|
|
elseif scancode == SCANCODE.RETURN.value then
|
|
forgeOpen(FORGE.chooser.files[FORGE.chooser.at])
|
|
elseif scancode == SCANCODE.ESCAPE.value then
|
|
singeQuit()
|
|
end
|
|
end
|
|
|
|
|
|
|
|
|
|
-- ===== Forge as a game ========================================================================
|
|
--
|
|
-- Everything above is a library; this is what runs when Forge is started from the menu. A scene
|
|
-- that drives the editor sets FORGE_LIBRARY and supplies its own callbacks instead.
|
|
--
|
|
-- Playing is a scriptPush of the built game. The engine runs this script again from the top when
|
|
-- the game ends, so the description being edited is noted in a file first and reopened on the way
|
|
-- back, which is what makes P feel like a button rather than a restart.
|
|
|
|
if not FORGE_LIBRARY then
|
|
|
|
local LAST_FILE = singeGetDataPath() .. "last.txt" -- What was open when play was pressed.
|
|
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
|
|
|
|
fontSelect(font)
|
|
fontQuality(FONT_QUALITY_BLENDED)
|
|
overlaySetResolution(720, 480)
|
|
|
|
-- Every key reaches the editor -- TAB, ENTER, the digits, the letters typed into a form --
|
|
-- rather than only the ones controls.cfg maps to switches, which is all a game gets by
|
|
-- default. The mouse and a pad still arrive as switches.
|
|
keyboardSetMode(MODE_FULL)
|
|
|
|
|
|
-- Saves, builds, and hands the game to the engine. Back here afterwards, on the same file.
|
|
function forgePlay()
|
|
local built = forgeBuild(singeGetDataPath() .. "preview.singe", FORGE.room)
|
|
local note = io.open(LAST_FILE, "w")
|
|
|
|
forgeSave()
|
|
if note ~= nil then
|
|
note:write(FORGE.path)
|
|
note:close()
|
|
end
|
|
scriptPush({ SCRIPT = built, VIDEO = forgeVideo() })
|
|
end
|
|
|
|
|
|
-- Where play left off, if it did. The note is read once and removed, so a fresh start shows
|
|
-- the chooser.
|
|
local function reopen()
|
|
local note = io.open(LAST_FILE, "r")
|
|
local path
|
|
|
|
if note == nil then
|
|
return false
|
|
end
|
|
path = note:read("l")
|
|
note:close()
|
|
os.remove(LAST_FILE)
|
|
if (path == nil) or not forgeBegin(path) then
|
|
return false
|
|
end
|
|
FORGE.message = "back from playing"
|
|
forgeRefresh()
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- ESC in the editor closes the description; twice, when it has unsaved changes.
|
|
local function editorEscape()
|
|
if FORGE.dirty and not FORGE.closing then
|
|
FORGE.closing = true
|
|
FORGE.message = "unsaved: S saves, ESC again leaves without saving"
|
|
forgeRefresh()
|
|
return
|
|
end
|
|
forgeClose()
|
|
forgeChooserScan()
|
|
end
|
|
|
|
|
|
function onOverlayUpdate()
|
|
if FORGE.game == nil then
|
|
forgeChooserDraw()
|
|
else
|
|
local x, y = mouseGetPosition(0)
|
|
|
|
forgeDraw(x, y)
|
|
end
|
|
|
|
return OVERLAY_UPDATED
|
|
end
|
|
|
|
|
|
-- Typed text, shifted and laid out as the person typed it; while it arrives, the keys' own
|
|
-- unshifted characters are not typed as well.
|
|
function onTextInput(text)
|
|
FORGE.textInput = true
|
|
forgeText(text)
|
|
end
|
|
|
|
|
|
function onKeyPressed(keysym, scancode)
|
|
if FORGE.game == nil then
|
|
forgeChooserKey(scancode)
|
|
elseif (scancode == SCANCODE.ESCAPE.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then
|
|
editorEscape()
|
|
elseif (scancode == SCANCODE.P.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then
|
|
forgePlay()
|
|
else
|
|
forgeKey(keysym, scancode)
|
|
end
|
|
end
|
|
|
|
|
|
-- The pad walks the chooser; in the editor the left mouse button is the pointer's press.
|
|
function onInputPressed(what)
|
|
-- A key masquerading as a switch (BACKSPACE as the mouse button) is handled as the key.
|
|
if authorSwitchIsKey(what) then
|
|
return
|
|
end
|
|
if FORGE.game == nil then
|
|
if what == SWITCH_DOWN then
|
|
forgeChooserKey(SCANCODE.DOWN.value)
|
|
elseif what == SWITCH_UP then
|
|
forgeChooserKey(SCANCODE.UP.value)
|
|
elseif (what == SWITCH_START1) or (what == SWITCH_BUTTON1) then
|
|
forgeChooserKey(SCANCODE.RETURN.value)
|
|
end
|
|
elseif what == SWITCH_BUTTON3 then
|
|
local x, y = mouseGetPosition(0)
|
|
|
|
forgePress(x, y)
|
|
end
|
|
end
|
|
|
|
|
|
function onInputReleased(what)
|
|
if (FORGE.game ~= nil) and (what == SWITCH_BUTTON3) and not authorSwitchIsKey(what) then
|
|
forgeRelease()
|
|
end
|
|
end
|
|
|
|
|
|
function onMouseMoved(x, y)
|
|
if FORGE.game ~= nil then
|
|
forgeDrag(x, y)
|
|
end
|
|
end
|
|
|
|
|
|
FORGE.chooser.manual = forgeChooserManual()
|
|
if not reopen() then
|
|
forgeChooserScan()
|
|
end
|
|
|
|
end
|