--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * 02110-1301, USA. * * --]] -- 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 four lists -- the room's entities, the kinds, 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, kinds, rules, or tracks. selected = nil, -- Index into the room's entities. kindName = nil, -- The selected kind. 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. 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 kind 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", "kinds", "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 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 kinds panel's thumbnails fit a square this many pixels across. local ORBIT_STEP = 10 -- Degrees the editor camera turns per key. 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. number = 0, string = "", boolean = true, entity = "self", kind = "", scancode = "SPACE", switch = "SWITCH_BUTTON1", expression = '""', lua = "-- your Lua here", file = "", state = "idle", track = "", room = "", table = nil } 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. local function spriteFor(file) if FORGE.sprites[file] == nil then if (file ~= "") and (lfs.attributes(file, "mode") == "file") then FORGE.sprites[file] = spriteLoad(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, 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 kind a kind carries, if any. local function behaviourOf(kind, name) for _, b in ipairs(kind.behaviours or {}) do if b.kind == name then return b end end return nil 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() .. "/" .. file end -- What a kind 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) / (look.frames or 1), spriteGetHeight(sprite) local fit = THUMB / math.max(w, h) local rect = ((look.frames or 1) > 1) and string.format(' rect="0 0 %d %d"', w, h) or "" return string.format('', escape(rooted(look.file)), rect, math.floor(w * fit + 0.5), math.floor(h * fit + 0.5)) end elseif look.kind == "text" then return 'Aa' elseif (look.kind == "none") or (look.kind == "grid") then return '..' end return string.format('', look.r or 180, look.g or 180, look.b or 190) end -- ===== Entities on the canvas ================================================================= -- What an entity looks like: its kind's look, with anything the entry overrides. function forgeLook(entry) local kind = FORGE.game.kinds[entry.kind] return (kind and kind.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) / (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) 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 kind, a rule, and a key alike: the same rows, the same keys. local function fieldRows(fields) local rows = {} for _, name in ipairs(fields.names) do local class = (name == FORGE.lastField) and "part here" or "part" rows[#rows + 1] = string.format('
%s %s
', class, escape(name), escape(tostring(fields.get(name)))) 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 .. '
' .. escape(FORGE.message) .. "
" 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] = string.format('
%s
', index, class, escape((entry.id or "?") .. " (" .. tostring(entry.kind) .. ")")) end if #rows == 0 then rows[1] = '
no entities here: A adds one
' end element("list").inner_rml = table.concat(rows) for index = 1, #room.entities do guiSetHandler(FORGE.gui, FORGE.document, "e" .. index, "click", function() forgeSelect(index) end) end detail(forgeFields(), string.format('
room %d of %d: %s
', FORGE.room, #FORGE.game.rooms, escape(room.name))) end local function refreshKinds() local rows = {} for _, name in ipairs(sortedKeys(FORGE.game.kinds)) do local class = (name == FORGE.kindName) and "row selected" or "row" rows[#rows + 1] = string.format('
%s%s
', class, thumbnail(FORGE.game.kinds[name].look or {}), escape(name)) end if #rows == 0 then rows[1] = '
no kinds yet: A adds one
' 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] = string.format('
%d. %s
', class, index, escape(rule.note or "rule")) if index == FORGE.rule then local parts = forgeParts(rule) rows[#rows + 1] = string.format('
on %s%s
', 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] = string.format('
%s %s
', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item))) end if #parts == 0 then rows[#rows + 1] = '
(C adds a condition, T an action)
' end end end if #rows == 0 then rows[1] = '
no rules yet: N adds one
' end element("list").inner_rml = table.concat(rows) detail(forgeFields(), string.format('
rule %d of %d
', 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] = string.format('
%s (by %s)
', class, escape(track.name or "?"), 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.points then rows[#rows + 1] = string.format('
at %s: %s, %s, %s%s
', mark, tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.z), key.stop and (" stop " .. escape(key.stop)) or "") else rows[#rows + 1] = string.format('
at %s: %s,%s %sx%s
', mark, 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] = '
(K adds a key at the cursor)
' end end end if #rows == 0 then rows[1] = '
no tracks here: N adds one
' end element("list").inner_rml = table.concat(rows) detail(forgeFields(), string.format('
cursor at %s (, . step [ ] leap)
', 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 = {} local shown = forgePickItems() for at, item in ipairs(shown) do local class = (at == FORGE.picker.at) and "row selected" or "row" rows[#rows + 1] = string.format('
%s
', class, escape(item.name)) end element("list").inner_rml = table.concat(rows) element("detail").inner_rml = escape(FORGE.picker.title) .. "
" .. escape((shown[FORGE.picker.at] or {}).help or "") .. '
type to narrow: ' .. escape(FORGE.picker.filter) .. '_
UP / DOWN choose, ENTER takes it, ESC leaves it
' 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 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 _, row in ipairs(forgeDialogueRows()) do local class = ({ dialogue = "row", node = "part", choice = "part", act = "part" })[row.level] if dialogueRowSelected(row) then class = (row.level == "dialogue") and "row selected" or "part here" end rows[#rows + 1] = string.format('
%s
', class, escape(row.label)) end if #rows == 0 then rows[1] = '
no dialogues yet: A adds one
' end element("list").inner_rml = table.concat(rows) detail(forgeFields()) end -- Moves the dialogue selection to a row of the outline. local function dialogueSelect(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 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 then refreshPicker() elseif FORGE.mode == "kinds" 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(file, "mode") == "file") then FORGE.video = videoLoad(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.kinds = FORGE.game.kinds 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.kindName = sortedKeys(FORGE.game.kinds)[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 FORGE.message = "panel at " .. FORGE.panelX forgeRefresh() 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 -- 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. A game -- over a disc names its video in the description; anything else plays over the menu's, since -- the engine wants one and the game draws over all of it anyway. 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, folder .. "/" .. script .. ".singe", forgeVideo(), overlayGetWidth(), overlayGetHeight())) dat:close() end FORGE.message = "released to " .. folder forgeRefresh() return folder 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 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) end end function forgeRelease() FORGE.held = nil FORGE.panelDrag = nil end -- Whether an x lies under the chrome as it is placed right now. function forgeOverPanel(x) return (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W) 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 FORGE.message = "moving the panel" forgeRefresh() 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 kinds' 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 kind = FORGE.game.kinds[entry.kind] or {} local look = kind.look or {} parts[#parts + 1] = tostring(entry.kind) .. ":" .. 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 kind = FORGE.game.kinds[entry.kind] or {} local look = kind.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. function forgeDraw(pointerX, pointerY) local room = forgeRoom() 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 kind = FORGE.game.kinds[entry.kind] local spot = kind and behaviourOf(kind, "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) if pointerX ~= nil then colorForeground(255, 220, 60, 255) overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) end guiDraw(FORGE.gui) 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() 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) if pointerX ~= nil then colorForeground(255, 220, 60, 255) overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) end guiDraw(FORGE.gui) 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 (track.boxes or track.points) or nil if keys and #keys > 0 then first = math.huge last = -math.huge for _, key in ipairs(keys) do first = math.min(first, key.at) last = math.max(last, key.at) 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 + (key.at - 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.kinds[FORGE.kindName or ""] == nil then FORGE.kindName = sortedKeys(FORGE.game.kinds)[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 kind 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 kind has, from a stem. local function uniqueKind(stem) local n = 1 if FORGE.game.kinds[stem] == nil then return stem end while FORGE.game.kinds[stem .. n] ~= nil do n = n + 1 end return stem .. n end function forgeKindAdd() local name = uniqueKind("thing") forgeRemember() FORGE.game.kinds[name] = { look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, vars = {}, behaviours = {} } FORGE.kindName = name FORGE.lastField = nil FORGE.dirty = true FORGE.message = "added kind " .. name forgeRefresh() return name end function forgeKindDuplicate() local kind = FORGE.game.kinds[FORGE.kindName or ""] local name if kind == nil then return nil end name = uniqueKind(FORGE.kindName) forgeRemember() FORGE.game.kinds[name] = copyOf(kind) FORGE.kindName = name FORGE.lastField = nil FORGE.dirty = true FORGE.message = "copied as " .. name forgeRefresh() return name end -- A kind still placed somewhere cannot go: the entities would have nothing to be. function forgeKindDelete() local name = FORGE.kindName if (name == nil) or (FORGE.game.kinds[name] == nil) then return false end for _, room in ipairs(FORGE.game.rooms) do for _, entry in ipairs(room.entities) do if entry.kind == name then FORGE.message = name .. " is placed in " .. room.name .. "; delete those first" forgeRefresh() return false end end end forgeRemember() FORGE.game.kinds[name] = nil FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] FORGE.dirty = true FORGE.message = "deleted kind " .. name forgeRefresh() return true end -- Renaming a kind renames every entity of it and every rule that names it. local function renameKind(from, to) for _, room in ipairs(FORGE.game.rooms) do for _, entry in ipairs(room.entities) do if entry.kind == from then entry.kind = 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 == "kind") 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 == "kind") and (item[key] == from) then item[key] = to end end end end end for _, kind in pairs(FORGE.game.kinds) do for _, b in ipairs(kind.behaviours or {}) do local entry = AUTHOR.behaviours[b.kind] for key, type_ in pairs(entry and entry.params or {}) do if (type_ == "kind") 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 kind -- that was just typed in has a value for every parameter and compiles at once. local function fillDefaults(t, params) for key, kind in pairs(params or {}) do if (t[key] == nil) and (FORGE_DEFAULTS[kind] ~= nil) then t[key] = FORGE_DEFAULTS[kind] end end end -- A kind'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 forgeKindFieldNames(kind) local names = { "name", "look" } local look = AUTHOR.looks[(kind.look or {}).kind] for _, key in ipairs(sortedKeys(look and look.params or kind.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(kind.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 forgeKindGet(kind, name) local group, key = string.match(name, "^([^.]+)%.(.+)$") if name == "name" then return FORGE.kindName elseif name == "look" then return (kind.look or {}).kind elseif name == "vars" then return varsText(kind.vars) elseif name == "behaviours" then local kinds = {} for _, b in ipairs(kind.behaviours or {}) do kinds[#kinds + 1] = b.kind end return table.concat(kinds, ", ") elseif group == "look" then return (kind.look or {})[key] elseif group ~= nil then local b = behaviourOf(kind, group) return b and b[key] or nil end return nil end function forgeKindSet(kind, name, value) local group, key = string.match(name, "^([^.]+)%.(.+)$") kind.look = kind.look or {} if name == "name" then if (value ~= "") and (value ~= FORGE.kindName) and (FORGE.game.kinds[value] == nil) then renameKind(FORGE.kindName, value) FORGE.game.kinds[value] = kind FORGE.game.kinds[FORGE.kindName] = nil FORGE.kindName = value end elseif name == "look" then if AUTHOR.looks[value] ~= nil then kind.look.kind = value fillDefaults(kind.look, AUTHOR.looks[value].params) else FORGE.message = "no look called " .. tostring(value) end elseif name == "vars" then kind.vars = varsFrom(value) elseif name == "behaviours" then -- A comma separated list of behaviours. One already carried keeps its values; a new one -- starts with defaults; one left out goes. local list = {} for want in string.gmatch(tostring(value), "[^,%s]+") do local b = behaviourOf(kind, want) or { kind = want } if AUTHOR.behaviours[want] ~= nil then fillDefaults(b, AUTHOR.behaviours[want].params) list[#list + 1] = b else FORGE.message = "no behaviour called " .. want end end kind.behaviours = list elseif group == "look" then kind.look[key] = value elseif group ~= nil then local b = behaviourOf(kind, group) if b ~= nil then b[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 kind in the middle of the canvas, clear of the panel, selected and -- ready to be moved. With no kinds yet, one is made first. function forgeEntityAdd(x, y) local room = forgeRoom() local kind = FORGE.kindName if (kind == nil) or (FORGE.game.kinds[kind] == nil) then kind = forgeKindAdd() end forgeRemember() room.entities[#room.entities + 1] = { kind = kind, id = uniqueId(kind), x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), y = math.floor(y or (overlayGetHeight() / 2)) } 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.kind) 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 kind declares, overridable here. function forgeEntityFieldNames(entry) local names = { "id", "kind", "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 kind = FORGE.game.kinds[entry.kind] or {} for _, key in ipairs(sortedKeys(kind.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 kind = FORGE.game.kinds[entry.kind] or {} if entry.vars and entry.vars[key] ~= nil then return entry.vars[key] end return (kind.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 == "kind" then if FORGE.game.kinds[value] ~= nil then entry.kind = value else FORGE.message = "no kind 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 kind when -- that kind has a hotspot behaviour, and to the room's walk areas otherwise. function forgePolygonBegin() local entry = forgeRoom().entities[FORGE.selected] local kind = entry and FORGE.game.kinds[entry.kind] or nil local spot = kind and behaviourOf(kind, "hotspot") or nil 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 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 kind = FORGE.game.kinds[entry.kind] behaviourOf(kind, "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 for key, kind in pairs(entry.params or {}) do if (kind == "kind") then item[key] = FORGE.kindName or "" elseif FORGE_DEFAULTS[kind] ~= nil then item[key] = FORGE_DEFAULTS[kind] end end return item end -- Adds a condition ("when"), a condition in the rule's any group ("any"), or an action ("act"). function forgeRuleAdd(kindName, name) local rule = FORGE.game.rules[FORGE.rule] local set = (kindName == "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 " .. kindName return false end forgeRemember() rule.when = rule.when or {} rule.act = rule.act or {} if kindName == "any" then rule.when.any = rule.when.any or {} list = rule.when.any else list = rule[kindName] 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 == "kind" then rule[key] = FORGE.kindName 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 out[#out + 1] = key .. " " .. tostring(item[key]) 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. function forgeTrackNew(name) local room = forgeRoom() forgeRemember() if 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 = "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, or points along a rail. function forgeTrackKeys(track) return track.boxes or track.points or {} 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.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 -- The picker's items that match what has been typed. function forgePickItems() local picker = FORGE.picker local shown = {} for _, item in ipairs(picker.items) do if (picker.filter == "") or item.name:lower():find(picker.filter:lower(), 1, true) then shown[#shown + 1] = item 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) 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) 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 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 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) 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. function forgeFieldIsFile(name) local group, key = string.match(name, "^([^.]+)%.(.+)$") if (name == "file") or (name == "document") or (name == "texture") or (name == "sky") then return true end if FORGE.mode == "kinds" then local kind = FORGE.game.kinds[FORGE.kindName or ""] if (group == "look") and kind and kind.look then local look = AUTHOR.looks[kind.look.kind] return (look and look.params[key]) == "file" elseif group then local behaviour = AUTHOR.behaviours[group] return (behaviour and behaviour.params[key]) == "file" end elseif FORGE.mode == "rules" then local part = forgeParts(FORGE.game.rules[FORGE.rule] or {})[FORGE.part] if part then local entry = AUTHOR.conditions[part.item[1]] or AUTHOR.actions[part.item[1]] return (entry and entry.params[name]) == "file" end end return false 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, kinds, rules, tracks -- 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 kind) 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 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 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") 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 == "kinds" then local names = sortedKeys(FORGE.game.kinds) local at = 0 for index, name in ipairs(names) do if name == FORGE.kindName then at = index end end if scancode == SCANCODE.DOWN.value then FORGE.kindName = names[math.min(at + 1, #names)] or FORGE.kindName FORGE.lastField = nil forgeRefresh() elseif scancode == SCANCODE.UP.value then FORGE.kindName = names[math.max(at - 1, 1)] or FORGE.kindName FORGE.lastField = nil forgeRefresh() elseif scancode == SCANCODE.A.value then forgeKindAdd() elseif scancode == SCANCODE.D.value then forgeKindDuplicate() elseif scancode == SCANCODE.DELETE.value then forgeKindDelete() 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.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.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 kind, 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. function forgeFieldNames(item) local keys = {} for key in pairs(item) do if key ~= 1 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 == "kinds" then local kind = FORGE.game.kinds[FORGE.kindName or ""] if kind == nil then return nil end return { names = forgeKindFieldNames(kind), get = function(key) return forgeKindGet(kind, key) end, set = function(key, value) forgeKindSet(kind, 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") then track[key] = value end FORGE.dirty = true forgeRefresh() end } end local keys = forgeTrackKeys(track) local box = keys[FORGE.key] return { names = 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 type(value) == "number" then box[key] = value if key == "at" then table.sort(keys, function(a, b) return a.at < b.at end) 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 room itself. return { names = { "name", "reset", "depthSort", "walk", "navFrom", "scaleBy" }, 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. function forgeRoomGet(room, key) if 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) if 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 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 function forgeEditCommit() if FORGE.editing == nil then return false end FORGE.editing.set(FORGE.editing.key, typedValue(FORGE.editing.text, FORGE.editing.was)) 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 = { key = fields.names[at], text = "", was = fields.get(fields.names[at]), set = fields.set } -- A field that names a file offers the files under the game's directory; ESC on the list -- leaves the field being typed instead. if forgeFieldIsFile(fields.names[at]) then local field = fields.names[at] forgePickBegin("a file for " .. field, forgeFileItems(), function(path) fields.set(field, path) FORGE.editing = nil FORGE.message = field .. " = " .. path end) 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 FORGE.editing.text = string.sub(FORGE.editing.text, 1, -2) elseif (keysym >= 32) and (keysym < 127) then FORGE.editing.text = FORGE.editing.text .. string.char(keysym) else return false end -- Shown as it is typed, so the value in the list is the value being entered. FORGE.editing.set(FORGE.editing.key, (FORGE.editing.text == "") and FORGE.editing.was or typedValue(FORGE.editing.text, FORGE.editing.was)) FORGE.message = FORGE.editing.key .. " = " .. FORGE.editing.text .. "_" forgeRefresh() return true 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 }, kinds = { 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 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } }, rooms = { { name = "start", entities = { { kind = "ground", id = "ground", x = 360, y = 440 }, { kind = "hero", id = "hero", x = 120, y = 380 }, { kind = "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 -- 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 if string.sub(name, -5) == ".game" 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 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 -- ===== 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 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 local chooser = { files = {}, at = 1 } local font = fontLoad("Singe/FreeSansBold.ttf", 15) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) local function chooserScan() chooser.files = forgeFiles() chooser.at = math.max(1, math.min(chooser.at, #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. local function extractManual() 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 local function chooserDraw() 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 chooser.manual ~= nil then local shown = 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(chooser.files) do local label = entry.name if entry.sample then label = label .. " (a copy will be made)" end if at == 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 -- 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 local function chooserKey(scancode) if scancode == SCANCODE.DOWN.value then chooser.at = math.min(chooser.at + 1, #chooser.files) elseif scancode == SCANCODE.UP.value then chooser.at = math.max(chooser.at - 1, 1) elseif scancode == SCANCODE.RETURN.value then forgeOpen(chooser.files[chooser.at]) elseif scancode == SCANCODE.ESCAPE.value then singeQuit() end 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() chooserScan() end function onOverlayUpdate() if FORGE.game == nil then chooserDraw() else local x, y = mouseGetPosition(0) forgeDraw(x, y) end return OVERLAY_UPDATED end function onKeyPressed(keysym, scancode) if FORGE.game == nil then chooserKey(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) if FORGE.game == nil then if what == SWITCH_DOWN then chooserKey(SCANCODE.DOWN.value) elseif what == SWITCH_UP then chooserKey(SCANCODE.UP.value) elseif (what == SWITCH_START1) or (what == SWITCH_BUTTON1) then chooserKey(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) then forgeRelease() end end function onMouseMoved(x, y) if FORGE.game ~= nil then forgeDrag(x, y) end end chooser.manual = extractManual() if not reopen() then chooserScan() end end