--[[ * * 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 (PLAN section 58). -- -- 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. -- -- The layout comes from the spike recorded in that section. 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 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. -- Framework is wanted for SCANCODE, which the key handling below names; AuthorCompile brings in -- the runtime and the manifest. A generated game loads Framework for itself, so the editor only -- discovered it needed it when the rule editor started naming keys. dofile("Singe/Framework.singe") dofile("Forge/AuthorCompile.singe") local lfs = require("lfs") FORGE = { game = nil, -- The description being edited. path = nil, selected = nil, -- Index into game.entities. dirty = false, gui = nil, document = nil, held = nil, -- The entity being dragged. 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. mode = "entities", -- or "rules". The panel is narrow; one list at a time reads better. rule = 1, -- Selected rule. part = 0, -- Selected condition or action within it; 0 is the rule itself. lastField = nil, -- The parameter 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. 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 entity wears until it is given a look. local NEW_H = 40 local function element(id) return rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id) 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 -- 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(entity) local look = entity.look or {} local w = look.w or 90 local h = look.h or 20 if look.kind == "sprite" then local sprite = spriteFor(look.file or "") if sprite ~= nil then w = spriteGetWidth(sprite) h = spriteGetHeight(sprite) end end return entity.x - w / 2, entity.y - 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) for index = #FORGE.game.entities, 1, -1 do local bx, by, bw, bh = forgeBounds(FORGE.game.entities[index]) if collidePointRect(x, y, bx, by, bw, bh) then return index 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 -- The fields of whatever is selected, one row each, with the one ENTER is on marked. This is the -- detail panel for an entity and for a condition or action 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 refreshEntities() local rows = {} local fields = forgeFields() for index, entity in ipairs(FORGE.game.entities) do local class = (index == FORGE.selected) and "row selected" or "row" rows[#rows + 1] = string.format('
%s
', index, class, escape(entity.id)) end if #rows == 0 then rows[1] = '
no entities yet: A adds one
' end element("list").inner_rml = table.concat(rows) for index = 1, #FORGE.game.entities do guiSetHandler(FORGE.gui, FORGE.document, "e" .. index, "click", function() forgeSelect(index) end) end if fields ~= nil then element("detail").inner_rml = fieldRows(fields) .. '
' .. escape(FORGE.message) .. "
" else element("detail").inner_rml = "nothing selected
" .. escape(FORGE.message) end 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 = {} local fields = forgeFields() for index, rule in ipairs(FORGE.game.rules or {}) 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) 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] = '
(empty: 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) element("detail").inner_rml = string.format("rule %d of %d
%s%s", FORGE.rule, #(FORGE.game.rules or {}), (fields ~= nil) and fieldRows(fields) or "", '
' .. escape(FORGE.message) .. "
") end -- A picker takes the list over while it is up: the vocabulary is what is being chosen from, and -- the panel is too narrow to show it beside anything else. local function refreshPicker() local rows = {} for at, item in ipairs(FORGE.picker.items) 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(FORGE.picker.items[FORGE.picker.at].help or "") .. '
UP / DOWN choose, ENTER takes it, ESC leaves it
' 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 == "rules") and "≡ Rules" or "≡ Entities" if FORGE.picker ~= nil then refreshPicker() elseif FORGE.mode == "rules" then refreshRules() else refreshEntities() end end function forgeBegin(path) FORGE.path = path FORGE.game = authorLoad(path) if FORGE.game == nil then return false end FORGE.game.entities = FORGE.game.entities or {} FORGE.game.rules = FORGE.game.rules or {} FORGE.game.layers = FORGE.game.layers or {} FORGE.selected = nil FORGE.rule = 1 FORGE.part = 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) 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 _, sprite in pairs(FORGE.sprites) do if sprite then spriteUnload(sprite) end end FORGE.gui = nil FORGE.document = nil FORGE.game = nil FORGE.path = nil FORGE.sprites = {} FORGE.editing = nil FORGE.picker = nil FORGE.held = nil FORGE.undo = {} FORGE.redo = {} end -- Moves the selected 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 entity = FORGE.game.entities[index] entity.x = math.floor(x) entity.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 -- 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 -- The video a description plays over. Its own when it names one, the menu's otherwise. function forgeVideo() return FORGE.game.video or "Singe/menuBackground.mkv" 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. function forgeBuild(outputFile) FORGE.game.source = FORGE.path local file = assert(io.open(outputFile, "w")) file:write(authorCompile(FORGE.game)) file:close() authorCopy(AUTHOR_RUNTIME, (string.match(outputFile, "^(.*[/\\])") or "") .. "Author.singe") FORGE.message = "built" forgeRefresh() return outputFile end 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 FORGE.held = forgePick(x, y) if FORGE.held ~= nil then local entity = FORGE.game.entities[FORGE.held] -- One undo step for the whole drag, taken before it starts. forgeRemember() FORGE.grabX = x - entity.x FORGE.grabY = y - entity.y if FORGE.mode ~= "entities" then FORGE.mode = "entities" end 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 PLAN section -- 58 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 FORGE.held ~= nil then forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY) end end -- Draws the description. Note that this draws the *description*, not a running game: there are -- no nodes here and no physics, which is why an entity can be dragged through a wall. function forgeDraw(pointerX, pointerY) colorBackground(18, 18, 26, 255) overlayClear() for index, entity in ipairs(FORGE.game.entities) do local bx, by, bw, bh = forgeBounds(entity) local look = entity.look or {} local sprite = (look.kind == "sprite") and spriteFor(look.file or "") or nil -- 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. colorForeground(look.r or 180, look.g or 180, look.b or 190, 255) if look.kind == "text" then colorForeground(look.r or 235, look.g or 235, look.b or 245, 255) if (look.text or "") ~= "" then fontPrint(entity.x, entity.y, look.text) end overlayBox(bx, by, bx + bw, by + bh) elseif sprite ~= nil then spriteDraw(sprite, bx, by) else for row = math.floor(by), math.floor(by + bh) do overlayLine(bx, row, bx + bw, row) end end if index == FORGE.selected then colorForeground(255, 210, 70, 255) overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2) overlayBox(entity.x - HANDLE, entity.y - HANDLE, entity.x + HANDLE, entity.y + HANDLE) end end -- The panel's drag tab. local tx, ty, tw, th = forgeTab() colorForeground(70, 70, 96, 255) for trow = ty, ty + th do overlayLine(tx, trow, tx + tw, trow) end 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 -- ===== 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. 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 -- 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.selected = FORGE.selected and math.min(FORGE.selected, #FORGE.game.entities) or nil if FORGE.selected == 0 then FORGE.selected = nil 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.dirty = true FORGE.message = message 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 -- ===== Entities =============================================================================== -- -- An entity is a position, a look and a list of behaviours. All of it is edited the same 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 entity has, from a stem. local function uniqueId(stem) local taken = {} local n = 1 for _, entity in ipairs(FORGE.game.entities) do taken[entity.id] = 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 box in the middle of the canvas, clear of the panel, selected and ready to be moved. function forgeEntityAdd(x, y) local entity = { id = uniqueId("thing"), x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), y = math.floor(y or (overlayGetHeight() / 2)), look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, behaviours = {} } forgeRemember() FORGE.game.entities[#FORGE.game.entities + 1] = entity FORGE.dirty = true FORGE.message = "added " .. entity.id forgeSelect(#FORGE.game.entities) return #FORGE.game.entities end -- A copy of the selected entity, a little to one side so the two can be told apart. function forgeEntityDuplicate() local entity = FORGE.game.entities[FORGE.selected] local twin if entity == nil then return nil end forgeRemember() twin = copyOf(entity) twin.id = uniqueId(entity.id) twin.x = entity.x + 20 twin.y = entity.y + 20 FORGE.game.entities[#FORGE.game.entities + 1] = twin FORGE.dirty = true FORGE.message = "copied " .. entity.id .. " as " .. twin.id forgeSelect(#FORGE.game.entities) return #FORGE.game.entities end function forgeEntityDelete() local entity = FORGE.game.entities[FORGE.selected] if entity == nil then return false end forgeRemember() table.remove(FORGE.game.entities, FORGE.selected) FORGE.dirty = true FORGE.message = "deleted " .. entity.id if #FORGE.game.entities == 0 then forgeSelect(nil) else forgeSelect(math.min(FORGE.selected, #FORGE.game.entities)) end return true end -- The sorted keys of a parameter 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 -- The behaviour of a kind an entity carries, if any. local function behaviourOf(entity, kind) for _, b in ipairs(entity.behaviours or {}) do if b.kind == kind then return b end end return nil end -- An entity's fields, in the order the panel shows them: what every entity has, then what its -- look takes, then its behaviours and what each of those takes. A behaviour's parameter is -- named kind.parameter, which is how it is typed as well. function forgeEntityFieldNames(entity) local names = { "id", "x", "y", "kind" } local look = AUTHOR.looks[(entity.look or {}).kind] for _, key in ipairs(sortedKeys(look and look.params or entity.look)) do if key ~= "kind" then names[#names + 1] = key end end names[#names + 1] = "behaviours" for _, b in ipairs(entity.behaviours or {}) do local kind = AUTHOR.behaviours[b.kind] for _, key in ipairs(sortedKeys(kind and kind.params or b)) do if key ~= "kind" then names[#names + 1] = b.kind .. "." .. key end end end return names end function forgeEntityGet(entity, name) local kind, key = string.match(name, "^([^.]+)%.(.+)$") if (name == "id") or (name == "x") or (name == "y") then return entity[name] elseif name == "kind" then return (entity.look or {}).kind elseif name == "behaviours" then local kinds = {} for _, b in ipairs(entity.behaviours or {}) do kinds[#kinds + 1] = b.kind end return table.concat(kinds, ", ") elseif kind ~= nil then local b = behaviourOf(entity, kind) return b and b[key] or nil end return (entity.look or {})[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 -- 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 then t[key] = FORGE_DEFAULTS[kind] end end end function forgeEntitySet(entity, name, value) local kind, key = string.match(name, "^([^.]+)%.(.+)$") entity.look = entity.look or {} if name == "id" then if (value ~= "") and (value ~= entity.id) then renameEverywhere(entity.id, value) entity.id = value end elseif (name == "x") or (name == "y") then if type(value) == "number" then entity[name] = math.floor(value) end elseif name == "kind" then if AUTHOR.looks[value] ~= nil then entity.look.kind = value fillDefaults(entity.look, AUTHOR.looks[value].params) else FORGE.message = "no look called " .. tostring(value) end elseif name == "behaviours" then -- A comma separated list of kinds. 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(entity, 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 entity.behaviours = list elseif kind ~= nil then local b = behaviourOf(entity, kind) if b ~= nil then b[key] = value end else entity.look[name] = value end FORGE.dirty = true forgeRefresh() 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. It is driven by keys rather than by the pointer: the bundled -- menu's document is driven by keys and the pad and has always worked that way, whereas whether a -- document receives the pointer is the open question recorded in PLAN section 58. A cabinet wants -- keys anyway. -- -- The vocabulary comes from AUTHOR, so a rule editor never needs changing when a condition or an -- action is added: it lists whatever the manifest declares. FORGE_DEFAULTS = { number = 0, string = "", boolean = true, entity = "", scancode = "SPACE", switch = "SWITCH_BUTTON1", expression = '""', lua = "-- your Lua here", file = "" } -- 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.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 == "entity" and #FORGE.game.entities > 0 then item[key] = FORGE.game.entities[FORGE.selected or 1].id else item[key] = FORGE_DEFAULTS[kind] end end return item end function forgeRuleAdd(kindName, name) local rule = FORGE.game.rules[FORGE.rule] local set = (kindName == "when") and AUTHOR.conditions or AUTHOR.actions local item = forgeMakePart(set, name) if (rule == nil) or (item == nil) then FORGE.message = "no such " .. kindName return false end local parts forgeRemember() rule[kindName] = rule[kindName] or {} rule[kindName][#rule[kindName] + 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. parts = forgeParts(rule) for at = 1, #parts do if parts[at].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() list = rule[part.kind] 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", when = {}, act = {} } FORGE.rule = #FORGE.game.rules FORGE.part = 0 FORGE.lastField = nil FORGE.dirty = true FORGE.message = "rule added" 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 -- 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 function forgeMode(mode) FORGE.mode = mode FORGE.lastField = nil FORGE.picker = nil forgeRefresh() end -- ===== Pickers ================================================================================ -- -- Choosing a condition, an action, 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); choose is called with -- the name picked. function forgePickBegin(title, set, choose) local items = {} for _, name in ipairs(sortedKeys(set)) do items[#items + 1] = { name = name, help = set[name].help } 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 } forgeRefresh() return true end -- A key while a list is up. Answers whether it was taken. function forgePickKey(scancode) local picker = FORGE.picker if picker == nil then return false end if scancode == SCANCODE.DOWN.value then picker.at = math.min(picker.at + 1, #picker.items) elseif scancode == SCANCODE.UP.value then picker.at = math.max(picker.at - 1, 1) elseif scancode == SCANCODE.RETURN.value then FORGE.picker = nil picker.choose(picker.items[picker.at].name) elseif scancode == SCANCODE.ESCAPE.value then FORGE.picker = nil FORGE.message = "left it" else return true end forgeRefresh() return true 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 or rules -- UP / DOWN move within the list -- LEFT/RIGHT move the panel out of the way -- ENTER type a value for what is selected; again for its next value -- ESC put the value back; otherwise close the description -- A add an entity D duplicate it -- N new rule C / T add a condition / an action from the list -- [ / ] move the rule up / down the sheet -- DELETE delete the selected entity, rule, condition or action -- U / R undo / redo S save -- B build P play (Forge as a game only) function forgeKey(keysym, scancode) local rules = FORGE.game.rules -- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain -- integers, so the comparison is against .value. Comparing against the table itself is always -- false with a real keyboard; it only appeared to work because a test passed the table. if forgePickKey(scancode) then 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 forgeMode((FORGE.mode == "rules") and "entities" or "rules") elseif scancode == SCANCODE.LEFT.value then forgePanelTo(FORGE.panelX - 40) elseif scancode == SCANCODE.RIGHT.value then forgePanelTo(FORGE.panelX + 40) elseif FORGE.mode == "entities" then if scancode == SCANCODE.DOWN.value then forgeSelect(math.min((FORGE.selected or 0) + 1, #FORGE.game.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() end else -- 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.C.value then if rules[FORGE.rule] ~= nil then forgePickBegin("a condition", AUTHOR.conditions, function(name) forgeRuleAdd("when", 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 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 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() 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 = { "note" }, get = function(key) return rule[key] end, set = function(key, value) rule[key] = value 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 } end local entity = FORGE.game.entities[FORGE.selected] if entity == nil then return nil end return { names = forgeEntityFieldNames(entity), get = function(key) return forgeEntityGet(entity, key) end, set = function(key, value) forgeEntitySet(entity, key, value) end } 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 } 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 } }, entities = { { id = "ground", x = 360, y = 440, look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, { id = "hero", x = 120, y = 380, look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, { id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } }, rules = { { note = "Run left", when = { { "keyHeld", key = "LEFT" } }, act = { { "run", entity = "hero", direction = -1 } } }, { note = "Run right", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } }, { note = "Jump, with ground underfoot", when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, act = { { "jump", entity = "hero" } } }, { note = "The readout, every frame", when = {}, act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_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) for row = y - 3, y + ROW_H - 6 do overlayLine(LIST_X - 10, row, LIST_X + 400, row) end 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") 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