--[[ * * 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. -- 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. message = "loaded" } local PANEL_W = 190 -- Matches AuthorEdit.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 function element(id) return rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id) 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. function forgeBounds(entity) local w = (entity.look and entity.look.w) or 90 local h = (entity.look and entity.look.h) or 20 return entity.x - w / 2, entity.y - h / 2, w, h end function forgeSelect(index) FORGE.selected = index forgeRefresh() end -- The topmost entity under a point, so overlapping things pick the one drawn last. function forgePick(x, y) local index 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 local function refreshEntities() local rows = {} local index 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 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 FORGE.selected ~= nil then local entity = FORGE.game.entities[FORGE.selected] element("detail").inner_rml = string.format("%s
x %d y %d
%s", escape(entity.id), entity.x or 0, entity.y or 0, 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 index local rule 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) local at 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)
' end end end if #rows == 0 then rows[1] = '
no rules yet
' end element("list").inner_rml = table.concat(rows) element("detail").inner_rml = string.format("rule %d of %d
%s", FORGE.rule, #(FORGE.game.rules or {}), escape(FORGE.message)) 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.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.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 -- 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.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. 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\tDATA = %q,\n\t\tVIDEO = \"Singe/menuBackground.mkv\",\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n", title, folder .. "/" .. script .. ".singe", folder, 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. function forgeBuild(outputFile) FORGE.game.source = FORGE.path local file = assert(io.open(outputFile, "w")) file:write(authorCompile(FORGE.game)) file:close() 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] FORGE.grabX = x - entity.x FORGE.grabY = y - entity.y 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) local index 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 row -- 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) 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() local trow 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 -- ===== 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. local 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 = {} local item 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 } local key local kind 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[1].id else item[key] = 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 local at rule[kindName] = rule[kindName] or {} rule[kindName][#rule[kindName] + 1] = item FORGE.dirty = true 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 local i if part == nil then return false end 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) FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", when = {}, act = {} } FORGE.rule = #FORGE.game.rules FORGE.part = 0 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 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 -- 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 keys = {} local out = {} local key 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) for _, key in ipairs(keys) 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 forgeRefresh() 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 the selected condition or action; again for its next value -- ESC put the value back -- N new rule DELETE delete the selected rule or part -- S save B build function forgeKey(keysym, scancode) local rules = FORGE.game.rules or {} -- 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 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.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)) 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.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") 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 the selected condition or action, each press moves to its next parameter, -- and ESCAPE puts back what was there. FORGE.editing = nil -- { key = name, text = typed so far, was = the value it started as } -- The parameters of a part, in the order the panel shows them, so typing walks them the same way. function forgeFieldNames(item) local keys = {} local key 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 -- 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 forgePartSet(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 forgePartSet(FORGE.editing.key, FORGE.editing.was) FORGE.editing = nil FORGE.message = "unchanged" forgeRefresh() return true end -- Starts editing, or moves on to the next parameter of the same part. Committing as it moves is -- what makes ENTER, ENTER, ENTER feel like filling in a form. function forgeEditNext() local rule = FORGE.game.rules[FORGE.rule] local parts = forgeParts(rule or {}) local part = parts[FORGE.part] local names local at = 0 local i if part == nil then return false end names = forgeFieldNames(part.item) if #names == 0 then FORGE.message = part.item[1] .. " takes no values" forgeRefresh() return false end if FORGE.editing ~= nil then forgeEditCommit() end for i = 1, #names do if names[i] == FORGE.lastField then at = i end end at = (at % #names) + 1 FORGE.lastField = names[at] FORGE.editing = { key = names[at], text = "", was = part.item[names[at]] } FORGE.message = "type a value for " .. 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. forgePartSet(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