1768 lines
52 KiB
Text
1768 lines
52 KiB
Text
--[[
|
|
*
|
|
* Singe 3
|
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 3
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
|
* 02110-1301, USA.
|
|
*
|
|
*
|
|
--]]
|
|
|
|
|
|
|
|
|
|
-- Forge: the authoring tool (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('<div class="%s">%s %s</div>', 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('<div id="e%d" class="%s">%s</div>', index, class, escape(entity.id))
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no entities yet: A adds one</div>'
|
|
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) .. '<div class="part">' .. escape(FORGE.message) .. "</div>"
|
|
else
|
|
element("detail").inner_rml = "nothing selected<br/>" .. 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('<div class="%s">%d. %s</div>', 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('<div class="%s">%s %s</div>', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item)))
|
|
end
|
|
if #parts == 0 then
|
|
rows[#rows + 1] = '<div class="part">(empty: C adds a condition, T an action)</div>'
|
|
end
|
|
end
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = '<div class="part">no rules yet: N adds one</div>'
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
element("detail").inner_rml = string.format("rule %d of %d<br/>%s%s", FORGE.rule, #(FORGE.game.rules or {}), (fields ~= nil) and fieldRows(fields) or "", '<div class="part">' .. escape(FORGE.message) .. "</div>")
|
|
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('<div class="%s">%s</div>', class, escape(item.name))
|
|
end
|
|
element("list").inner_rml = table.concat(rows)
|
|
element("detail").inner_rml = escape(FORGE.picker.title) .. "<br/>" .. escape(FORGE.picker.items[FORGE.picker.at].help or "") .. '<div class="part">UP / DOWN choose, ENTER takes it, ESC leaves it</div>'
|
|
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
|