From 79e5fce23eaeebbb1dde5b7d2a28fbb82c2f6e65 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Mon, 14 Sep 2026 22:32:53 -0500 Subject: [PATCH] More Forge work. --- CHANGELOG | 55 + CMakeLists.txt | 5 +- assets/Forge/Author.singe | 455 +++-- assets/Forge/AuthorCompile.singe | 104 +- assets/Forge/Forge.rml | 30 +- assets/Forge/Forge.singe | 1905 ++++++++++++++++----- assets/Forge/kit/LICENSE | 8 + assets/Forge/kit/README.txt | 11 + assets/Forge/kit/click.wav | 3 + assets/Forge/kit/clip.mkv | 3 + assets/Forge/kit/coin.png | 3 + assets/Forge/kit/coin.wav | 3 + assets/Forge/kit/crate.png | 3 + assets/Forge/kit/door.png | 3 + assets/Forge/kit/door.wav | 3 + assets/Forge/kit/enemy.png | 3 + assets/Forge/kit/fox.glb | 3 + assets/Forge/kit/hall.png | 3 + assets/Forge/kit/hero.png | 3 + assets/Forge/kit/hit.wav | 3 + assets/Forge/kit/jump.wav | 3 + assets/Forge/kit/key.png | 3 + assets/Forge/kit/loop.ogg | 3 + assets/Forge/kit/rock.png | 3 + assets/Forge/kit/ship.png | 3 + assets/Forge/kit/shot.png | 3 + assets/Forge/kit/shot.wav | 3 + assets/Forge/kit/spike.png | 3 + assets/Forge/kit/target.png | 3 + assets/Forge/kit/tiles.png | 3 + assets/Forge/kit/yard.png | 3 + assets/Forge/tutorials/01-tenMinutes.game | 76 + assets/Forge/tutorials/02-platformer.game | 144 ++ assets/Forge/tutorials/03-sprites.game | 156 ++ assets/Forge/tutorials/04-shmup.game | 135 ++ assets/Forge/tutorials/05-qte.game | 140 ++ assets/Forge/tutorials/06-gun.game | 115 ++ assets/Forge/tutorials/07-adventure.game | 244 +++ assets/Forge/tutorials/08-scene.game | 129 ++ assets/Forge/tutorials/09-rail.game | 215 +++ assets/Forge/tutorials/10-release.game | 193 +++ cmake/Superbuild.cmake | 5 +- docs/Forge.adoc | 1250 +++++++++++++- docs/ForgeVocabulary.adoc | 72 +- docs/Manual.adoc | 30 +- docs/images/tutorials/01-chooser.png | 3 + docs/images/tutorials/01-entities.png | 3 + docs/images/tutorials/01-moved.png | 3 + docs/images/tutorials/01-playing.png | 3 + docs/images/tutorials/01-recoloured.png | 3 + docs/images/tutorials/01-rules.png | 3 + docs/images/tutorials/01-saved.png | 3 + docs/images/tutorials/01-types.png | 3 + docs/images/tutorials/02-cave.png | 3 + docs/images/tutorials/02-coinType.png | 3 + docs/images/tutorials/02-coins.png | 3 + docs/images/tutorials/02-placed.png | 3 + docs/images/tutorials/02-playing.png | 3 + docs/images/tutorials/02-rule.png | 3 + docs/images/tutorials/02-rules.png | 3 + docs/images/tutorials/03-coinSpin.png | 3 + docs/images/tutorials/03-heroType.png | 3 + docs/images/tutorials/03-jumpRule.png | 3 + docs/images/tutorials/03-music.png | 3 + docs/images/tutorials/03-playing.png | 3 + docs/images/tutorials/04-cleared.png | 3 + docs/images/tutorials/04-hitRule.png | 3 + docs/images/tutorials/04-placed.png | 3 + docs/images/tutorials/04-playing.png | 3 + docs/images/tutorials/04-rules.png | 3 + docs/images/tutorials/04-ship.png | 3 + docs/images/tutorials/04-types.png | 3 + docs/images/tutorials/05-disc.png | 3 + docs/images/tutorials/05-playing.png | 3 + docs/images/tutorials/05-prompts.png | 3 + docs/images/tutorials/05-rules.png | 3 + docs/images/tutorials/05-window.png | 3 + docs/images/tutorials/06-firstKey.png | 3 + docs/images/tutorials/06-playing.png | 3 + docs/images/tutorials/06-rules.png | 3 + docs/images/tutorials/06-tracks.png | 3 + docs/images/tutorials/06-types.png | 3 + docs/images/tutorials/07-dialogue.png | 3 + docs/images/tutorials/07-hall.png | 3 + docs/images/tutorials/07-hotspots.png | 3 + docs/images/tutorials/07-playing.png | 3 + docs/images/tutorials/07-verbRules.png | 3 + docs/images/tutorials/07-walkArea.png | 3 + docs/images/tutorials/08-hero.png | 3 + docs/images/tutorials/08-meshes.png | 3 + docs/images/tutorials/08-playing.png | 3 + docs/images/tutorials/08-rules.png | 3 + docs/images/tutorials/08-scene.png | 3 + docs/images/tutorials/08-viewport.png | 3 + docs/images/tutorials/09-hitRule.png | 3 + docs/images/tutorials/09-placed.png | 3 + docs/images/tutorials/09-playing.png | 3 + docs/images/tutorials/09-rail.png | 3 + docs/images/tutorials/09-room.png | 3 + docs/images/tutorials/09-rules.png | 3 + docs/images/tutorials/09-zombie.png | 3 + docs/images/tutorials/10-arcadeRules.png | 3 + docs/images/tutorials/10-bezel.png | 3 + docs/images/tutorials/10-playing.png | 3 + docs/images/tutorials/10-released.png | 3 + src/gui.cpp | 8 + src/singe.c | 194 ++- testScripts/author/adventure2d.game | 26 +- testScripts/author/adventure3d.game | 28 +- testScripts/author/bowling.game | 24 +- testScripts/author/breakout.game | 24 +- testScripts/author/fmv.game | 8 +- testScripts/author/fps.game | 28 +- testScripts/author/gunvideo.game | 14 +- testScripts/author/maze.game | 16 +- testScripts/author/painted.game | 18 +- testScripts/author/platformer.game | 12 +- testScripts/author/platformer3d.game | 14 +- testScripts/author/pool.game | 16 +- testScripts/author/qte.game | 8 +- testScripts/author/racing.game | 20 +- testScripts/author/railshooter.game | 44 +- testScripts/author/rhythm.game | 10 +- testScripts/author/shmup.game | 10 +- testScripts/author/space.game | 12 +- testScripts/author/tail.game | 16 +- testScripts/author/thirdperson.game | 24 +- testScripts/author/towerdefence.game | 16 +- testScripts/author/waves.game | 37 + testScripts/scene52.singe | 2 +- testScripts/scene54.singe | 2 +- testScripts/scene55.singe | 4 +- testScripts/scene57.singe | 10 +- testScripts/scene58.singe | 2 +- testScripts/scene59.singe | 2 +- testScripts/scene61.singe | 2 + testScripts/scene63.singe | 2 +- testScripts/scene69.singe | 18 +- testScripts/scene70.singe | 2 +- testScripts/scene75.singe | 2 + testScripts/scene82.singe | 35 +- testScripts/scene83.singe | 99 ++ testScripts/scene84.singe | 186 ++ testScripts/scene85.singe | 130 ++ testScripts/scene86.singe | 232 +++ testScripts/scene87.singe | 208 +++ testScripts/scene88.singe | 254 +++ testScripts/scene89.singe | 353 ++++ testScripts/scene90.singe | 246 +++ testScripts/scene91.singe | 308 ++++ testScripts/scene92.singe | 175 ++ testScripts/scene93.singe | 51 + testScripts/scene94.singe | 54 + testScripts/scene95.singe | 56 + testScripts/tutorialDrive.singe | 186 ++ util/forgeKit.py | 371 ++++ util/forgeShots.py | 38 + util/forgeVocabulary.lua | 9 +- 158 files changed, 8509 insertions(+), 883 deletions(-) create mode 100644 assets/Forge/kit/LICENSE create mode 100644 assets/Forge/kit/README.txt create mode 100644 assets/Forge/kit/click.wav create mode 100644 assets/Forge/kit/clip.mkv create mode 100644 assets/Forge/kit/coin.png create mode 100644 assets/Forge/kit/coin.wav create mode 100644 assets/Forge/kit/crate.png create mode 100644 assets/Forge/kit/door.png create mode 100644 assets/Forge/kit/door.wav create mode 100644 assets/Forge/kit/enemy.png create mode 100644 assets/Forge/kit/fox.glb create mode 100644 assets/Forge/kit/hall.png create mode 100644 assets/Forge/kit/hero.png create mode 100644 assets/Forge/kit/hit.wav create mode 100644 assets/Forge/kit/jump.wav create mode 100644 assets/Forge/kit/key.png create mode 100644 assets/Forge/kit/loop.ogg create mode 100644 assets/Forge/kit/rock.png create mode 100644 assets/Forge/kit/ship.png create mode 100644 assets/Forge/kit/shot.png create mode 100644 assets/Forge/kit/shot.wav create mode 100644 assets/Forge/kit/spike.png create mode 100644 assets/Forge/kit/target.png create mode 100644 assets/Forge/kit/tiles.png create mode 100644 assets/Forge/kit/yard.png create mode 100644 assets/Forge/tutorials/01-tenMinutes.game create mode 100644 assets/Forge/tutorials/02-platformer.game create mode 100644 assets/Forge/tutorials/03-sprites.game create mode 100644 assets/Forge/tutorials/04-shmup.game create mode 100644 assets/Forge/tutorials/05-qte.game create mode 100644 assets/Forge/tutorials/06-gun.game create mode 100644 assets/Forge/tutorials/07-adventure.game create mode 100644 assets/Forge/tutorials/08-scene.game create mode 100644 assets/Forge/tutorials/09-rail.game create mode 100644 assets/Forge/tutorials/10-release.game create mode 100644 docs/images/tutorials/01-chooser.png create mode 100644 docs/images/tutorials/01-entities.png create mode 100644 docs/images/tutorials/01-moved.png create mode 100644 docs/images/tutorials/01-playing.png create mode 100644 docs/images/tutorials/01-recoloured.png create mode 100644 docs/images/tutorials/01-rules.png create mode 100644 docs/images/tutorials/01-saved.png create mode 100644 docs/images/tutorials/01-types.png create mode 100644 docs/images/tutorials/02-cave.png create mode 100644 docs/images/tutorials/02-coinType.png create mode 100644 docs/images/tutorials/02-coins.png create mode 100644 docs/images/tutorials/02-placed.png create mode 100644 docs/images/tutorials/02-playing.png create mode 100644 docs/images/tutorials/02-rule.png create mode 100644 docs/images/tutorials/02-rules.png create mode 100644 docs/images/tutorials/03-coinSpin.png create mode 100644 docs/images/tutorials/03-heroType.png create mode 100644 docs/images/tutorials/03-jumpRule.png create mode 100644 docs/images/tutorials/03-music.png create mode 100644 docs/images/tutorials/03-playing.png create mode 100644 docs/images/tutorials/04-cleared.png create mode 100644 docs/images/tutorials/04-hitRule.png create mode 100644 docs/images/tutorials/04-placed.png create mode 100644 docs/images/tutorials/04-playing.png create mode 100644 docs/images/tutorials/04-rules.png create mode 100644 docs/images/tutorials/04-ship.png create mode 100644 docs/images/tutorials/04-types.png create mode 100644 docs/images/tutorials/05-disc.png create mode 100644 docs/images/tutorials/05-playing.png create mode 100644 docs/images/tutorials/05-prompts.png create mode 100644 docs/images/tutorials/05-rules.png create mode 100644 docs/images/tutorials/05-window.png create mode 100644 docs/images/tutorials/06-firstKey.png create mode 100644 docs/images/tutorials/06-playing.png create mode 100644 docs/images/tutorials/06-rules.png create mode 100644 docs/images/tutorials/06-tracks.png create mode 100644 docs/images/tutorials/06-types.png create mode 100644 docs/images/tutorials/07-dialogue.png create mode 100644 docs/images/tutorials/07-hall.png create mode 100644 docs/images/tutorials/07-hotspots.png create mode 100644 docs/images/tutorials/07-playing.png create mode 100644 docs/images/tutorials/07-verbRules.png create mode 100644 docs/images/tutorials/07-walkArea.png create mode 100644 docs/images/tutorials/08-hero.png create mode 100644 docs/images/tutorials/08-meshes.png create mode 100644 docs/images/tutorials/08-playing.png create mode 100644 docs/images/tutorials/08-rules.png create mode 100644 docs/images/tutorials/08-scene.png create mode 100644 docs/images/tutorials/08-viewport.png create mode 100644 docs/images/tutorials/09-hitRule.png create mode 100644 docs/images/tutorials/09-placed.png create mode 100644 docs/images/tutorials/09-playing.png create mode 100644 docs/images/tutorials/09-rail.png create mode 100644 docs/images/tutorials/09-room.png create mode 100644 docs/images/tutorials/09-rules.png create mode 100644 docs/images/tutorials/09-zombie.png create mode 100644 docs/images/tutorials/10-arcadeRules.png create mode 100644 docs/images/tutorials/10-bezel.png create mode 100644 docs/images/tutorials/10-playing.png create mode 100644 docs/images/tutorials/10-released.png create mode 100644 testScripts/author/waves.game create mode 100644 testScripts/scene83.singe create mode 100644 testScripts/scene84.singe create mode 100644 testScripts/scene85.singe create mode 100644 testScripts/scene86.singe create mode 100644 testScripts/scene87.singe create mode 100644 testScripts/scene88.singe create mode 100644 testScripts/scene89.singe create mode 100644 testScripts/scene90.singe create mode 100644 testScripts/scene91.singe create mode 100644 testScripts/scene92.singe create mode 100644 testScripts/scene93.singe create mode 100644 testScripts/scene94.singe create mode 100644 testScripts/scene95.singe create mode 100644 testScripts/tutorialDrive.singe create mode 100644 util/forgeKit.py create mode 100644 util/forgeShots.py diff --git a/CHANGELOG b/CHANGELOG index 93f03dd33..fc726113f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,61 @@ SINGE 3.00 API Changes ----------- +- singeScreenshot takes an optional file name, so a script that makes + pictures on purpose can name them. + +- guiDraw's place in a script's drawing now decides what is over the GUI: + what is drawn on the overlay after the call goes on a layer composited + over that document, so a gun's sight or a pointer drawn after the HUD + shows over it. Before, every GUI was drawn over the whole overlay. + +- onTextInput, in MODE_FULL: the text a key press produced, shifted and + laid out as typed, since a keysym is the unshifted key and a script + reading keysyms could never type a capital or a quote. + +- In MODE_FULL a pad or mouse button reaches the script as the switch it + is mapped to, as the manual always said; it arrived as a keysym of 0. + +- guiSetHandler set again after a document's elements were rebuilt (a + list through inner_rml) now attaches to the new element with that id. + It kept the old listener, on an element that no longer existed, so a + rebuilt list's rows stopped answering clicks. + +- TAB reaches a script even when a GUI document is up: RmlUi took it for + moving focus and never handed it back, so a game whose keys include + TAB lost it. It goes to the GUI only while a text field is being typed + in. + +- Forge has tutorials: ten sittings at the front of Forge.pdf, each + ending with a game that plays, with the kit of pictures, sounds, a clip, + and a model they use packed in Forge.game, and the finished description + of each in the chooser. Each tutorial's scene presses the keys the text + names and checks the result, so the book cannot drift from the editor. + On the way the editor gained the game's own form (title, players, vars, + verbs, layers, and each layer's parameters), the digits as panel keys, + DELETE to empty a field, X to release a game with every file it names, + a file picker that keeps an unset field unset, and a panel that steps + aside while a polygon is drawn. What an entity is made from is now a + type, not a kind: types = { hero = ... } in a description, type = "hero" + on an entity and in an event's filter, and the Types panel. Forge's + launcher takes every key (MODE_FULL), so ENTER and TAB reach it, and + its panel's rows and fields take clicks. A value is edited in place: + it starts selected, so typing replaces it, and the arrows put a caret + into it. A field whose values are a fixed set opens a dropdown of them + -- true and false, the scancodes, the switches, the looks, the events, + the words a parameter takes, the game's own types, rooms, entities, + states, tracks, and nodes -- opened on the value it has, under its own + row, upward when there is no room below. The panel's list and fields + box scroll, with bars, and keep the chosen row in view. The game's + name at the top of the panel, or a press on empty canvas, brings the + game's own form back. A waves track (W in the tracks) spawns a type + by count and interval at a moment, a disc frame, or a rail camera's + stop, from an entity or a list of them in turn, or at a point; and + soundDone says when a clip playSound or a sound behaviour started has + ended. Flat + bodies are the size their looks are drawn at (they were half), and a + platformer stands on its point. + - spriteFlip mirrors a sprite left to right, top to bottom, or both, for every draw of it, its frames included, so art that faces one way is enough for a character that walks both ways. diff --git a/CMakeLists.txt b/CMakeLists.txt index afb28ddf1..8922ccbd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -309,11 +309,14 @@ if(LUA_INTERPRETER) COMMENT "Writing the Forge vocabulary" ) endif() +# The book embeds the tutorials' pictures and includes their shipped descriptions, so any of +# those changing renders it again; the globs are re-run on every build. +file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/images/tutorials/* ${CMAKE_SOURCE_DIR}/assets/Forge/tutorials/*.game) add_custom_command( OUTPUT ${MANUAL_DIR}/Forge.pdf COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR} COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf - DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc + DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc ${FORGE_BOOK_PARTS} COMMENT "Rendering Forge.pdf" VERBATIM ) diff --git a/assets/Forge/Author.singe b/assets/Forge/Author.singe index b670b5c31..c0468037a 100644 --- a/assets/Forge/Author.singe +++ b/assets/Forge/Author.singe @@ -33,8 +33,8 @@ -- The nouns (FORGE.md section 3): -- -- layers which of the engine's own layers the game uses. A genre is only a choice of layers. --- kinds what a thing is: a look, behaviours, and vars. Everything placed or spawned is --- an instance of a kind. +-- types what a thing is: a look, behaviours, and vars. Everything placed or spawned is +-- an instance of a type. -- rooms where things are. A room keeps its state when it is left unless it says reset. -- rules a trigger, conditions, and actions. Actions that take time run as a coroutine. -- @@ -51,7 +51,7 @@ -- string text, quoted -- boolean true or false -- entity "self", "other", or an entity id --- kind a kind's name +-- type a type's name -- scancode a key, from SCANCODE -- switch a switch, from SWITCH_* -- file a file name @@ -88,6 +88,8 @@ local MOUSE_LEFT = nil -- Filled from SWITCH_BUTTON3 once Framework is i local SAY_SECONDS_MIN = 1.5 -- A line stays up at least this long, local SAY_PER_CHAR = 0.05 -- plus this per character. local SOUND_MAX_VOLUME = 63 -- soundPlay's loudest; Forge volumes are 0 to 100 of it. +local PLATFORMER_STATES = { idle = true, walk = true, jump = true, fall = true } -- The states a platformer sets itself. +local AUTHOR_GROUND = { r = 18, g = 18, b = 26 } -- Behind a flat game with no disc: the editor's own dark. local SPAWN_MARGIN = 40 -- How far off the overlay a projectile may go before it is dropped. local FRAME_CAP = 0.1 -- The longest step a frame may take, so a stall does not teleport. local RAY_REACH = 500 -- How far a gun's ray goes, in world units. @@ -121,6 +123,7 @@ local sequences = {} -- Running coroutines, by rule and instance. local timers = {} -- Runtime timers: { instance, name, at, every }. local onceSeen = {} -- once tags, by scope. local sounds = {} -- Loaded clips by file. +local playing = {} -- What soundPlay is playing, by channel: the file and the instance that played it, for soundDone. local sprites = {} -- Loaded images by file. local pointers = {} -- Injected pointer positions by player, for tests. local pressedNow = {} -- Keys and switches that went down this frame. @@ -148,12 +151,12 @@ function authorEntity(id) end --- Every live instance of a kind, in draw order. -function authorEach(kind) +-- Every live instance of a type, in draw order. +function authorEach(typeName) local list = {} for _, instance in ipairs(AUTHOR_LIVE) do - if instance.alive and (instance.kind == kind) then + if instance.alive and (instance.type == typeName) then list[#list + 1] = instance end end @@ -162,8 +165,8 @@ function authorEach(kind) end -function authorCount(kind) - return #authorEach(kind) +function authorCount(typeName) + return #authorEach(typeName) end @@ -190,7 +193,7 @@ function authorY(instance) end --- An instance's field, for expressions: x, y, z, id, kind, state, or one of its vars. +-- An instance's field, for expressions: x, y, z, id, type, state, or one of its vars. function authorField(instance, name) if instance == nil then return nil @@ -203,7 +206,7 @@ function authorField(instance, name) local _, _, z = nodeGetPosition(instance.node) return z - elseif (name == "id") or (name == "kind") then + elseif (name == "id") or (name == "type") then return instance[name] end @@ -285,7 +288,7 @@ end function authorBounds(instance) local x, y = authorPosition(instance) local w, h = authorSize(instance) - local scale = authorDepthScale(y) + local scale = authorDepthScaleOf(instance) w = w * scale h = h * scale @@ -299,6 +302,28 @@ end -- How big a thing at a height on the picture is drawn, in a room whose scaleBy says things -- further up the picture are further away. 1 everywhere else. +-- Where an instance sorts in a room that sorts by depth: its y, except that a line of text is a +-- readout and stays in front of everything. +function authorDepthY(instance) + if instance.look.kind == "text" then + return math.huge + end + + return authorY(instance) +end + + +-- The room's depth scale for an instance: what stands on the floor (anchor feet) shrinks with +-- distance; a backdrop, a readout, or a thing floating in the air is drawn as it is. +function authorDepthScaleOf(instance) + if instance.look.anchor ~= "feet" then + return 1 + end + + return authorDepthScale(select(2, authorPosition(instance))) +end + + function authorDepthScale(y) local table_ = AUTHOR_ROOM and AUTHOR_ROOM.scaleBy or nil @@ -348,14 +373,32 @@ end -- A picture loaded once per file -- or, with a frame count, as a sheet of that many frames across, -- which is its own sprite since the engine keeps the frames with the handle. +-- Where a file the description names really is. A game built in the editor names files as the +-- engine sees them from the games directory (Forge/kit/hero.png); a released game carries its +-- files beside itself and names them from there (kit/hero.png), and the compiled script says +-- where it is in AUTHOR_DIR. The name is tried beside the game first, then as it is. +function authorFile(name) + if (AUTHOR_DIR or "") ~= "" and not name:match("^[/\\]") and not name:match("^%a:") then + local probe = io.open(AUTHOR_DIR .. name, "rb") + + if probe ~= nil then + probe:close() + return AUTHOR_DIR .. name + end + end + + return name +end + + local function spriteFor(file, frames) local key = file .. "#" .. tostring(frames or 1) if sprites[key] == nil then if (frames or 1) > 1 then - sprites[key] = spriteLoadFrames(frames, file) + sprites[key] = spriteLoadFrames(frames, authorFile(file)) else - sprites[key] = spriteLoad(file) + sprites[key] = spriteLoad(authorFile(file)) end end @@ -365,7 +408,7 @@ end local function soundFor(file) if sounds[file] == nil then - sounds[file] = soundLoad(file) + sounds[file] = soundLoad(authorFile(file)) end return sounds[file] @@ -409,7 +452,7 @@ AUTHOR.layers.hud = { begin = function(layer) local gui = guiNew(overlayGetWidth(), overlayGetHeight()) - hud = { gui = gui, document = guiLoad(gui, layer.document), bind = layer.bind or {}, shown = {} } + hud = { gui = gui, document = guiLoad(gui, authorFile(layer.document)), bind = layer.bind or {}, shown = {} } end } @@ -417,7 +460,7 @@ AUTHOR.layers.music = { help = "A track that plays from the start, looping.", params = { file = "file", volume = "number" }, begin = function(layer) - music = musicLoad(layer.file) + music = musicLoad(authorFile(layer.file)) if layer.volume then musicSetVolume(layer.volume) end @@ -440,7 +483,7 @@ AUTHOR.layers.scene3d = { physicsSet2D(false) physicsSetGravity(0, layer.gravity or -9.8, 0) if layer.sky then - sceneSetSky(layer.sky) + sceneSetSky(authorFile(layer.sky)) end if layer.ambient then sceneSetAmbient(layer.ambient.r or 40, layer.ambient.g or 40, layer.ambient.b or 50) @@ -496,13 +539,14 @@ AUTHOR.layers.parser = { AUTHOR.looks.box = { help = "A filled rectangle, centred on the instance -- or standing on it, with anchor feet.", params = { w = "number", h = "number", r = "number", g = "number", b = "number", anchor = "string" }, + choices = { anchor = { "feet" } }, size = function(instance) return instance.look.w or 20, instance.look.h or 20 end, draw = function(instance) local look = instance.look local x, y = authorPosition(instance) local w, h = authorSize(instance) - local scale = authorDepthScale(y) + local scale = authorDepthScaleOf(instance) local top = (look.anchor == "feet") and (y - h * scale) or (y - h * scale / 2) colorForeground(look.r or 255, look.g or 255, look.b or 255, 255) @@ -517,6 +561,7 @@ AUTHOR.looks.box = { AUTHOR.looks.sprite = { help = "An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both.", params = { file = "file", frames = "number", anchor = "string", faces = "string" }, + choices = { anchor = { "feet" }, faces = { "right", "left", "none" } }, load = function(instance) instance.sprite = spriteFor(instance.look.file, instance.look.frames) end, @@ -533,7 +578,7 @@ AUTHOR.looks.sprite = { draw = function(instance) local x, y = authorPosition(instance) local w, h = authorSize(instance) - local scale = authorDepthScale(y) * (instance.scale or 1) + local scale = authorDepthScaleOf(instance) * (instance.scale or 1) local top = (instance.look.anchor == "feet") and (y - h * scale) or (y - h * scale / 2) local frames = instance.look.frames or 1 local frame = instance.vars.frame or 1 @@ -547,7 +592,7 @@ AUTHOR.looks.sprite = { local own = instance.ownSprite if own == nil then - own = (frames > 1) and spriteLoadFrames(frames, instance.look.file) or spriteLoad(instance.look.file) + own = (frames > 1) and spriteLoadFrames(frames, authorFile(instance.look.file)) or spriteLoad(authorFile(instance.look.file)) instance.ownSprite = own end spriteFlip(own, mirror, false) @@ -642,7 +687,7 @@ AUTHOR.looks.none = { local function modelFor(file) if models[file] == nil then - models[file] = modelLoad(file) + models[file] = modelLoad(authorFile(file)) end return models[file] @@ -704,6 +749,7 @@ end AUTHOR.looks.mesh = { help = "A box, sphere, cylinder, or plane with a colour or a texture. Floors, walls, crates, targets. An occluder is not seen but hides what is behind it: a pillar that is only in the painting.", params = { shape = "string", w = "number", h = "number", d = "number", r = "number", g = "number", b = "number", texture = "file", unlit = "boolean", metallic = "number", roughness = "number", occluder = "boolean" }, + choices = { shape = { "box", "sphere", "cylinder", "plane" } }, load = function(instance) local look = instance.look local material = materialNew() @@ -713,7 +759,7 @@ AUTHOR.looks.mesh = { -- what materialSetTexture takes for those. if look.texture and look.texture ~= "" then if look.texture:lower():match("%.ktx2$") then - materialSetTexture(material, look.texture) + materialSetTexture(material, authorFile(look.texture)) else materialSetTexture(material, spriteFor(look.texture)) end @@ -749,6 +795,7 @@ AUTHOR.looks.mesh = { AUTHOR.looks.light = { help = "A point or spot light, or a sun, on the instance.", params = { type = "string", r = "number", g = "number", b = "number", intensity = "number", range = "number", shadow = "boolean" }, + choices = { type = { "point", "spot", "sun" } }, load = function(instance) local look = instance.look local kind = (look.type == "spot") and LIGHT_SPOT or ((look.type == "sun") and LIGHT_DIRECTIONAL or LIGHT_POINT) @@ -863,7 +910,7 @@ AUTHOR.behaviours.solid = { if AUTHOR_3D then bodyNew(instance.node, kind, SHAPE_BOX, w, h, d) else - bodyNew(instance.node, kind, SHAPE_BOX, w / 2, h / 2, w / 2) + bodyNew(instance.node, kind, SHAPE_BOX, w, h, w) end instance.hasBody = true end @@ -872,6 +919,7 @@ AUTHOR.behaviours.solid = { AUTHOR.behaviours.body = { help = "A thing physics moves: a crate, a ball, debris. Mass, bounce, friction, and buoyancy as Jolt takes them.", params = { shape = "string", mass = "number", bounce = "number", friction = "number", buoyancy = "number" }, + choices = { shape = { "box", "sphere" } }, attach = function(instance, params) local w, h, d = authorUnscaledSize(instance) @@ -880,7 +928,7 @@ AUTHOR.behaviours.body = { elseif AUTHOR_3D then bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, d) else - bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w / 2, h / 2, w / 2) + bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, w) end if params.mass then bodySetMass(instance.node, params.mass) @@ -907,7 +955,7 @@ AUTHOR.behaviours.trigger = { if AUTHOR_3D then bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w, h, d) else - bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w / 2, h / 2, w / 2) + bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w, h, w) end bodySetTrigger(instance.node, true) instance.hasBody = true @@ -1010,6 +1058,7 @@ AUTHOR.behaviours.animator = { AUTHOR.behaviours.camera = { help = "The scene is drawn from this instance. fixed looks at a point; follow keeps an offset from a target; first sits on a target's eyes and the mouse looks; orbit circles a target; rail rides a track of points with stops.", params = { mode = "string", target = "string", x = "number", y = "number", z = "number", lookX = "number", lookY = "number", lookZ = "number", track = "track", lag = "number", eye = "number" }, + choices = { mode = { "fixed", "follow", "first", "orbit", "rail" } }, attach = function(instance, params) instance.camera = params instance.yaw = 0 @@ -1067,7 +1116,7 @@ AUTHOR.behaviours.camera = { } AUTHOR.behaviours.platformer = { - help = "Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump.", + help = "Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. Its point is its feet, so its look stands on it. Sets the var facing, and the var state to idle, walk, jump, or fall unless a rule set another.", params = { speed = "number", jump = "number" }, attach = function(instance, params) local w, h = authorSize(instance) @@ -1075,12 +1124,31 @@ AUTHOR.behaviours.platformer = { instance.speed = params.speed or 200 instance.jump = params.jump or 500 instance.player = true + -- A player stands on its node, so the node is the feet; the look is drawn to match. + instance.look.anchor = "feet" playerNew(instance.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h) end, step = function(instance) + local drive = instance.vars.drive + local state = instance.vars.state + -- The rules say which way; this clears it each frame so releasing a key stops the run. - playerMove(instance.node, instance.vars.drive * instance.speed) + playerMove(instance.node, drive * instance.speed) instance.vars.drive = 0 + -- Which way it faces and what it is doing, for a sprite look and a frames behaviour: a + -- state a rule set (hit, dead) is left alone until the rule sets another. + if drive ~= 0 then + instance.vars.facing = (drive < 0) and "left" or "right" + end + if (state == nil) or PLATFORMER_STATES[state] then + if not playerIsOnGround(instance.node) then + local _, vy = playerGetVelocity(instance.node) + + instance.vars.state = (vy < 0) and "jump" or "fall" + else + instance.vars.state = (drive ~= 0) and "walk" or "idle" + end + end end } @@ -1154,8 +1222,8 @@ AUTHOR.behaviours.mover = { } AUTHOR.behaviours.shooter = { - help = "Spawns a kind from an offset while the var fire is set (or always, with auto), no faster than the rate.", - params = { spawns = "kind", rate = "number", offsetX = "number", offsetY = "number", auto = "boolean" }, + help = "Spawns a type from an offset while the var fire is set (or always, with auto), no faster than the rate.", + params = { spawns = "type", rate = "number", offsetX = "number", offsetY = "number", auto = "boolean" }, attach = function(instance, params) instance.shooter = params instance.lastShot = -math.huge @@ -1237,8 +1305,9 @@ AUTHOR.behaviours.timer = { } AUTHOR.behaviours.spawner = { - help = "Makes instances of a kind every so many seconds at its own position or at one of its points, up to max alive at once and total in all.", - params = { spawns = "kind", every = "number", max = "number", total = "number", points = "table", pick = "string" }, + help = "Makes instances of a type every so many seconds at its own position or at one of its points, up to max alive at once and total in all.", + params = { spawns = "type", every = "number", max = "number", total = "number", points = "table", pick = "string" }, + choices = { pick = { "turn", "random" } }, attach = function(instance, params) instance.spawner = params instance.nextAt = authorTime() + (params.every or 1) @@ -1281,14 +1350,14 @@ AUTHOR.behaviours.sound = { attach = function(instance, params) instance.sounds = params if params.spawn then - authorPlaySound(params.spawn, params.volume) + authorPlaySound(params.spawn, params.volume, instance) end end, on = function(instance, event) local file = instance.sounds[event] if file and (event ~= "spawn") then - authorPlaySound(file, instance.sounds.volume) + authorPlaySound(file, instance.sounds.volume, instance) end end } @@ -1296,6 +1365,7 @@ AUTHOR.behaviours.sound = { AUTHOR.behaviours.gun = { help = "The pointer as a gun for one player: a press of the trigger raises hit on what is under it, or miss. Ammo counts down; a shot off the screen reloads. With aim centre it fires from the middle of the picture -- a first-person gun.", params = { player = "number", trigger = "switch", ammo = "number", reload = "string", aim = "string" }, + choices = { reload = { "offscreen" }, aim = { "centre" } }, attach = function(instance, params) instance.gun = params instance.trigger = switchValue(params.trigger) @@ -1448,6 +1518,7 @@ AUTHOR.behaviours.patrol = { AUTHOR.behaviours.vehicle = { help = "A car, motorcycle, tank, or boat on the engine's vehicle physics, driven by the vars dy (throttle, up is forward) and dx (steering) from keys. Wheels hang at the four corners the sizes say; a boat needs none.", params = { type = "string", wheelX = "number", wheelZ = "number", radius = "number", width = "number", suspension = "number", torque = "number", mass = "number", thrust = "number" }, + choices = { type = { "car", "motorcycle", "tank", "boat" } }, attach = function(instance, params) local w, h, d = authorSize(instance) local kind = ({ car = VEHICLE_CAR, motorcycle = VEHICLE_MOTORCYCLE, tank = VEHICLE_TANK, boat = VEHICLE_BOAT })[params.type or "car"] or VEHICLE_CAR @@ -1547,6 +1618,7 @@ AUTHOR.behaviours.thrust = { AUTHOR.behaviours.joint = { help = "Joins this body to another's -- or to the world, with no other -- by a hinge, a ball, or a slider through a world point along an axis.", params = { type = "string", other = "string", ax = "number", ay = "number", az = "number", dx = "number", dy = "number", dz = "number" }, + choices = { type = { "hinge", "ball", "slider" } }, attach = function(instance, params) instance.jointWanted = params end, @@ -1573,8 +1645,8 @@ AUTHOR.behaviours.joint = { } AUTHOR.behaviours.turret = { - help = "Fires at the nearest instance of a kind within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself.", - params = { targets = "kind", range = "number", rate = "number", spawns = "kind", damage = "number", speed = "number" }, + help = "Fires at the nearest instance of a type within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself.", + params = { targets = "type", range = "number", rate = "number", spawns = "type", damage = "number", speed = "number" }, attach = function(instance, params) instance.turret = params instance.lastShot = -math.huge @@ -1723,6 +1795,7 @@ AUTHOR.behaviours.water = { AUTHOR.behaviours.soft = { help = "A mesh look made soft: a cloth, or a body inflated to a pressure.", params = { type = "string", pressure = "number", stiffness = "number", mass = "number" }, + choices = { type = { "cloth", "body" } }, attach = function(instance, params) softNew(instance.node, (params.type == "cloth") and SOFT_CLOTH or SOFT_BODY) if params.pressure then @@ -1778,17 +1851,17 @@ AUTHOR.events.roomStart = { help = "A room has been entered.", AUTHOR.events.roomEnd = { help = "A room is about to be left.", filter = { room = "room" } } AUTHOR.events.pressed = { help = "A key or a switch went down.", filter = { key = "scancode", switch = "switch", player = "number" } } AUTHOR.events.released = { help = "A key or a switch came up.", filter = { key = "scancode", switch = "switch", player = "number" } } -AUTHOR.events.collision = { help = "An instance of kind a began touching one of kind b (self is a, other is b).", filter = { a = "kind", b = "kind" } } -AUTHOR.events.hit = { help = "A gun shot landed on self; other is the gun.", filter = { kind = "kind", player = "number" } } +AUTHOR.events.collision = { help = "An instance of type a began touching one of type b (self is a, other is b).", filter = { a = "type", b = "type" } } +AUTHOR.events.hit = { help = "A gun shot landed on self; other is the gun.", filter = { type = "type", player = "number" } } AUTHOR.events.miss = { help = "A gun shot hit nothing; self is the gun. event.offscreen says whether it left the screen.", filter = { player = "number" } } -AUTHOR.events.death = { help = "self's health reached zero.", filter = { kind = "kind" } } -AUTHOR.events.spawn = { help = "self has just been made.", filter = { kind = "kind" } } -AUTHOR.events.timer = { help = "A timer on self went off; event.name says which.", filter = { name = "string", kind = "kind" } } +AUTHOR.events.death = { help = "self's health reached zero.", filter = { type = "type" } } +AUTHOR.events.spawn = { help = "self has just been made.", filter = { type = "type" } } +AUTHOR.events.timer = { help = "A timer on self went off; event.name says which.", filter = { name = "string", type = "type" } } AUTHOR.events.frameReached = { help = "The disc passed a frame.", filter = { frame = "number" } } AUTHOR.events.gameOver = { help = "The game ended.", filter = {} } -AUTHOR.events.enter = { help = "An instance of kind b entered a trigger of kind a (self is the trigger, other what entered).", filter = { a = "kind", b = "kind" } } -AUTHOR.events.leave = { help = "An instance of kind b left a trigger of kind a.", filter = { a = "kind", b = "kind" } } -AUTHOR.events.arrived = { help = "A seeking instance reached its target.", filter = { kind = "kind" } } +AUTHOR.events.enter = { help = "An instance of type b entered a trigger of type a (self is the trigger, other what entered).", filter = { a = "type", b = "type" } } +AUTHOR.events.leave = { help = "An instance of type b left a trigger of type a.", filter = { a = "type", b = "type" } } +AUTHOR.events.arrived = { help = "A seeking instance reached its target.", filter = { type = "type" } } AUTHOR.events.stopped = { help = "A rail camera reached a stop; event.name says which. pathNext moves it on.", filter = { name = "string" } } AUTHOR.events.railEnd = { help = "A rail camera reached the end of its track.", filter = {} } AUTHOR.events.verb = { help = "A verb was used on a hotspot (self), with an item or not: \"use key on door\". The most specific rule wins.", filter = { verb = "string", target = "string", item = "string" }, exclusive = true } @@ -1797,8 +1870,9 @@ AUTHOR.events.branchOpen = { help = "A branch's window opened; event.move say AUTHOR.events.branchTaken = { help = "The move was made in time and the disc went to the success frame.", filter = {} } AUTHOR.events.branchMissed = { help = "The window closed without the move and the disc went to the fail frame.", filter = {} } AUTHOR.events.midi = { help = "A MIDI note came in on the port the game opened; event.pitch and event.velocity say which and how hard. (pitch, since note is what a rule's comment is called.)", filter = { pitch = "number" } } -AUTHOR.events.patrolEnd = { help = "A patrolling instance reached the last of its points.", filter = { kind = "kind" } } -AUTHOR.events.animationDone = { help = "A clip that does not loop ended on self; event.state says which state it was for.", filter = { kind = "kind", state = "state" } } +AUTHOR.events.patrolEnd = { help = "A patrolling instance reached the last of its points.", filter = { type = "type" } } +AUTHOR.events.animationDone = { help = "A clip that does not loop ended on self; event.state says which state it was for.", filter = { type = "type", state = "state" } } +AUTHOR.events.soundDone = { help = "A clip that playSound or a sound behaviour started has ended; event.file says which, and self is the instance that played it.", filter = { file = "file", type = "type" } } -- ===== Conditions ============================================================================= @@ -1916,13 +1990,15 @@ AUTHOR.conditions.has = { AUTHOR.conditions.hover = { help = "A player's pointer is over the named hotspot.", params = { target = "string", player = "number" }, - emit = function(p) return "(authorHover(" .. (p.player or "1") .. ") == " .. p.target .. ")" end + defaults = { player = 1 }, + emit = function(p) return "(authorHover(" .. p.player .. ") == " .. p.target .. ")" end } AUTHOR.conditions.once = { help = "Only the first time this rule would run -- per instance and per room unless scope is \"game\".", params = { tag = "string", scope = "string" }, - emit = function(p) return "authorOnce(" .. p.tag .. ", self, " .. (p.scope or "nil") .. ")" end + optional = { scope = true }, + emit = function(p) return "authorOnce(" .. p.tag .. ", self, " .. p.scope .. ")" end } @@ -1946,7 +2022,8 @@ AUTHOR.actions.jump = { AUTHOR.actions.moveTo = { help = "Put an instance somewhere.", params = { entity = "entity", x = "number", y = "number", z = "number" }, - emit = function(p) return "authorMoveTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" end + defaults = { z = 0 }, + emit = function(p) return "authorMoveTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end } AUTHOR.actions.setText = { @@ -1970,13 +2047,15 @@ AUTHOR.actions.addScore = { AUTHOR.actions.setVar = { help = "Set a var on an instance, or on the game with no entity.", params = { entity = "entity", name = "string", value = "expression" }, - emit = function(p) return "authorSetVar(" .. (p.entity or "nil") .. ", " .. p.name .. ", " .. p.value .. ")" end + optional = { entity = true }, + emit = function(p) return "authorSetVar(" .. p.entity .. ", " .. p.name .. ", " .. p.value .. ")" end } AUTHOR.actions.addVar = { help = "Add to a var on an instance, or on the game with no entity.", params = { entity = "entity", name = "string", amount = "number" }, - emit = function(p) return "authorAddVar(" .. (p.entity or "nil") .. ", " .. p.name .. ", " .. p.amount .. ")" end + optional = { entity = true }, + emit = function(p) return "authorAddVar(" .. p.entity .. ", " .. p.name .. ", " .. p.amount .. ")" end } AUTHOR.actions.setState = { @@ -1992,9 +2071,10 @@ AUTHOR.actions.damage = { } AUTHOR.actions.spawn = { - help = "Make an instance of a kind at a point.", - params = { kind = "kind", x = "number", y = "number", z = "number" }, - emit = function(p) return "authorSpawn(" .. p.kind .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" end + help = "Make an instance of a type at a point.", + params = { type = "type", x = "number", y = "number", z = "number" }, + defaults = { z = 0 }, + emit = function(p) return "authorSpawn(" .. p.type .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end } AUTHOR.actions.destroy = { @@ -2006,7 +2086,8 @@ AUTHOR.actions.destroy = { AUTHOR.actions.playSound = { help = "Play a clip, at a volume of its own (0 to 100) under the game's.", params = { file = "file", volume = "number" }, - emit = function(p) return "authorPlaySound(" .. p.file .. ", " .. (p.volume or "100") .. ")" end + defaults = { volume = 100 }, + emit = function(p) return "authorPlaySound(" .. p.file .. ", " .. p.volume .. ", self)" end } AUTHOR.actions.playMusic = { @@ -2024,14 +2105,16 @@ AUTHOR.actions.stopMusic = { AUTHOR.actions.emit = { help = "A burst of particles at an instance: an explosion, sparks, a puff.", params = { entity = "entity", count = "number", r = "number", g = "number", b = "number", speed = "number" }, - emit = function(p) return "authorEmit(" .. p.entity .. ", " .. (p.count or "20") .. ", " .. (p.r or "255") .. ", " .. (p.g or "200") .. ", " .. (p.b or "60") .. ", " .. (p.speed or "150") .. ")" end + defaults = { count = 20, r = 255, g = 200, b = 60, speed = 150 }, + emit = function(p) return "authorEmit(" .. p.entity .. ", " .. p.count .. ", " .. p.r .. ", " .. p.g .. ", " .. p.b .. ", " .. p.speed .. ")" end } AUTHOR.actions.say = { help = "Show a line for a while, and wait for it. With a hud it goes in the element called say; otherwise it is drawn.", params = { text = "expression", seconds = "number" }, + optional = { seconds = true }, waits = true, - emit = function(p) return "authorSay(" .. p.text .. ", " .. (p.seconds or "nil") .. ")" end + emit = function(p) return "authorSay(" .. p.text .. ", " .. p.seconds .. ")" end } AUTHOR.actions.wait = { @@ -2044,7 +2127,8 @@ AUTHOR.actions.wait = { AUTHOR.actions.flash = { help = "Flash the screen a colour.", params = { r = "number", g = "number", b = "number", seconds = "number" }, - emit = function(p) return "authorFlash(" .. (p.r or "255") .. ", " .. (p.g or "255") .. ", " .. (p.b or "255") .. ", " .. (p.seconds or "0.2") .. ")" end + defaults = { r = 255, g = 255, b = 255, seconds = 0.2 }, + emit = function(p) return "authorFlash(" .. p.r .. ", " .. p.g .. ", " .. p.b .. ", " .. p.seconds .. ")" end } AUTHOR.actions.timerStart = { @@ -2056,7 +2140,8 @@ AUTHOR.actions.timerStart = { AUTHOR.actions.goTo = { help = "Go to a room, taking the named entity along and placing it at a point there -- or where the entity called at stands in that room.", params = { room = "room", entity = "entity", x = "number", y = "number", z = "number", at = "string" }, - emit = function(p) return "authorGoTo(" .. p.room .. ", " .. (p.entity or "nil") .. ", " .. (p.x or "nil") .. ", " .. (p.y or "nil") .. ", " .. (p.z or "nil") .. ", " .. (p.at or "nil") .. ")" end + optional = { entity = true, x = true, y = true, z = true, at = true }, + emit = function(p) return "authorGoTo(" .. p.room .. ", " .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ", " .. p.at .. ")" end } AUTHOR.actions.reload = { @@ -2092,7 +2177,8 @@ AUTHOR.actions.pathNext = { AUTHOR.actions.playAnimation = { help = "Play a model's clip, once or looping.", params = { entity = "entity", clip = "string", loop = "boolean" }, - emit = function(p) return "authorPlayAnimation(" .. p.entity .. ", " .. p.clip .. ", " .. (p.loop or "false") .. ")" end + defaults = { loop = false }, + emit = function(p) return "authorPlayAnimation(" .. p.entity .. ", " .. p.clip .. ", " .. p.loop .. ")" end } AUTHOR.actions.lookAt = { @@ -2104,13 +2190,16 @@ AUTHOR.actions.lookAt = { AUTHOR.actions.push = { help = "Shove a body, or a ragdoll's bone, along a direction.", params = { entity = "entity", x = "number", y = "number", z = "number", bone = "string" }, - emit = function(p) return "authorPush(" .. p.entity .. ", " .. (p.x or "0") .. ", " .. (p.y or "0") .. ", " .. (p.z or "0") .. ", " .. (p.bone or "nil") .. ")" end + defaults = { x = 0, y = 0, z = 0 }, + optional = { bone = true }, + emit = function(p) return "authorPush(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ", " .. p.bone .. ")" end } AUTHOR.actions.shake = { help = "Shake the camera for a moment.", params = { amount = "number", seconds = "number" }, - emit = function(p) return "authorShake(" .. (p.amount or "0.3") .. ", " .. (p.seconds or "0.4") .. ")" end + defaults = { amount = 0.3, seconds = 0.4 }, + emit = function(p) return "authorShake(" .. p.amount .. ", " .. p.seconds .. ")" end } AUTHOR.actions.cameraCut = { @@ -2120,23 +2209,26 @@ AUTHOR.actions.cameraCut = { } AUTHOR.actions.spawnAtPointer = { - help = "Make an instance of a kind where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D.", - params = { kind = "kind", player = "number" }, - emit = function(p) return "authorSpawnAtPointer(" .. p.kind .. ", " .. (p.player or "1") .. ")" end + help = "Make an instance of a type where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D.", + params = { type = "type", player = "number" }, + defaults = { player = 1 }, + emit = function(p) return "authorSpawnAtPointer(" .. p.type .. ", " .. p.player .. ")" end } AUTHOR.actions.walkTo = { help = "Send a walker to a point, and wait until it gets there.", params = { entity = "entity", x = "number", y = "number", z = "number" }, + defaults = { z = 0 }, waits = true, - emit = function(p) return "authorWalkTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" end + emit = function(p) return "authorWalkTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end } AUTHOR.actions.walkToPointer = { help = "Send a walker where the player's pointer is, and wait.", params = { entity = "entity", player = "number" }, + defaults = { player = 1 }, waits = true, - emit = function(p) return "authorWalkToPointer(" .. p.entity .. ", " .. (p.player or "1") .. ")" end + emit = function(p) return "authorWalkToPointer(" .. p.entity .. ", " .. p.player .. ")" end } AUTHOR.actions.walkToHotspot = { @@ -2149,7 +2241,8 @@ AUTHOR.actions.walkToHotspot = { AUTHOR.actions.face = { help = "Turn a walker to face left or right, or toward an instance.", params = { entity = "entity", direction = "string", target = "entity" }, - emit = function(p) return "authorFace(" .. p.entity .. ", " .. (p.direction or "nil") .. ", " .. (p.target or "nil") .. ")" end + optional = { direction = true, target = true }, + emit = function(p) return "authorFace(" .. p.entity .. ", " .. p.direction .. ", " .. p.target .. ")" end } AUTHOR.actions.give = { @@ -2192,20 +2285,23 @@ AUTHOR.actions.talk = { AUTHOR.actions.fade = { help = "Fade the picture to black, or back (with out false), and wait.", params = { seconds = "number", out = "boolean" }, + defaults = { seconds = FADE_DEFAULT, out = true }, waits = true, - emit = function(p) return "authorFade(" .. (p.seconds or tostring(FADE_DEFAULT)) .. ", " .. (p.out or "true") .. ")" end + emit = function(p) return "authorFade(" .. p.seconds .. ", " .. p.out .. ")" end } AUTHOR.actions.saveGame = { help = "Keep the whole game -- room, positions, vars, inventory -- in a numbered slot.", params = { slot = "number" }, - emit = function(p) return "authorSaveGame(" .. (p.slot or "1") .. ")" end + defaults = { slot = 1 }, + emit = function(p) return "authorSaveGame(" .. p.slot .. ")" end } AUTHOR.actions.loadGame = { help = "Bring a slot back.", params = { slot = "number" }, - emit = function(p) return "authorLoadGame(" .. (p.slot or "1") .. ")" end + defaults = { slot = 1 }, + emit = function(p) return "authorLoadGame(" .. p.slot .. ")" end } AUTHOR.actions.die = { @@ -2236,8 +2332,8 @@ AUTHOR.actions.lua = { -- ===== Instances ============================================================================== -local function nextId(kind) - local id = kind .. "#" .. nextSerial +local function nextId(typeName) + local id = typeName .. "#" .. nextSerial nextSerial = nextSerial + 1 @@ -2260,19 +2356,19 @@ local function copyOf(value) end --- Makes an instance of a kind. overrides is an entities entry: id, x, y, z, and vars. -local function make(kind, x, y, z, overrides) - local def = AUTHOR_GAME.kinds[kind] +-- Makes an instance of a type. overrides is an entities entry: id, x, y, z, and vars. +local function make(typeName, x, y, z, overrides) + local def = AUTHOR_GAME.types[typeName] local instance if def == nil then - debugPrint("Author: no kind called '" .. tostring(kind) .. "'") + debugPrint("Author: no type called '" .. tostring(typeName) .. "'") return nil end overrides = overrides or {} instance = { - id = overrides.id or nextId(kind), - kind = kind, + id = overrides.id or nextId(typeName), + type = typeName, def = def, look = copyOf(def.look or { kind = "none" }), behaviours = def.behaviours or {}, @@ -2286,7 +2382,7 @@ local function make(kind, x, y, z, overrides) instance.vars[key] = copyOf(value) end for key, value in pairs(overrides) do - if (key ~= "id") and (key ~= "x") and (key ~= "y") and (key ~= "z") and (key ~= "rx") and (key ~= "ry") and (key ~= "rz") and (key ~= "scale") and (key ~= "kind") and (key ~= "vars") then + if (key ~= "id") and (key ~= "x") and (key ~= "y") and (key ~= "z") and (key ~= "rx") and (key ~= "ry") and (key ~= "rz") and (key ~= "scale") and (key ~= "type") and (key ~= "vars") then instance.vars[key] = copyOf(value) end end @@ -2333,8 +2429,8 @@ local function make(kind, x, y, z, overrides) end -function authorSpawn(kind, x, y, z, overrides) - local instance = make(kind, x, y, z, overrides) +function authorSpawn(typeName, x, y, z, overrides) + local instance = make(typeName, x, y, z, overrides) if instance ~= nil then authorEvent("spawn", { self = instance }) @@ -2561,8 +2657,28 @@ end -- A clip at a Forge volume, 0 to 100, under the engine's master. -function authorPlaySound(file, volume) - soundPlay(soundFor(file), 0, math.floor(math.max(0, math.min(100, volume or 100)) * SOUND_MAX_VOLUME / 100 + 0.5)) +-- Plays a clip at a volume of its own, remembering who played it so soundDone can say so. +function authorPlaySound(file, volume, owner) + local channel = soundPlay(soundFor(file), 0, math.floor(math.max(0, math.min(100, volume or 100)) * SOUND_MAX_VOLUME / 100 + 0.5)) + + if channel and (channel >= 0) then + playing[channel] = { file = file, self = owner } + end + + return channel +end + + +-- The engine says a channel has finished: soundDone for the clip that was on it, with the +-- instance that played it as self. +function authorSoundDone(channel) + local was = playing[channel] + + if was == nil then + return + end + playing[channel] = nil + authorEvent("soundDone", { self = was.self, file = was.file }) end @@ -2570,7 +2686,7 @@ function authorPlayMusic(file) if music then musicStop(music) end - music = musicLoad(file) + music = musicLoad(authorFile(file)) musicPlay(music, -1) end @@ -2831,6 +2947,7 @@ function authorRailStep(instance, dt) rail.waiting = before.stop rail.t = before.at authorEvent("stopped", { self = instance, name = before.stop }) + authorWavesAtStop(before.stop) end local k = 0 @@ -2873,7 +2990,7 @@ function authorRailStep(instance, dt) end --- Physics reports a contact between two bodies; the rules hear about it by kind. +-- Physics reports a contact between two bodies; the rules hear about it by type. function authorCollision(nodeA, nodeB, x, y, z, speed) local a = authorInstanceOf(nodeA) local b = authorInstanceOf(nodeB) @@ -3124,11 +3241,11 @@ function authorDistance3D(a, b) end -function authorSpawnAtPointer(kind, player) +function authorSpawnAtPointer(typeName, player) local x, y, z = authorPointerWorld(player) if x ~= nil then - return authorSpawn(kind, x, y, z) + return authorSpawn(typeName, x, y, z) end return nil @@ -3528,7 +3645,7 @@ local function snapshotRoom(list) if instance.alive then local x, y, z = authorPosition(instance) - entities[#entities + 1] = { id = instance.id, kind = instance.kind, x = x, y = y, z = z, vars = instance.vars } + entities[#entities + 1] = { id = instance.id, type = instance.type, x = x, y = y, z = z, vars = instance.vars } end end @@ -3650,7 +3767,7 @@ local runRule -- Runs a rule for an event: once, with the event's own instance as self, or once per instance --- of the rule's kind when the event has none. Answers whether it ran for anything. +-- of the rule's type when the event has none. Answers whether it ran for anything. local function dispatch(rule, event) if rule.each and (event.self == nil) then local any = false @@ -3710,25 +3827,25 @@ end local function matches(rule, event) for name, want in pairs(rule.filter) do - if name == "kind" then - if (event.self == nil) or (event.self.kind ~= want) then + if name == "type" then + if (event.self == nil) or (event.self.type ~= want) then return false end elseif name == "a" then - if (event.self == nil) or (event.self.kind ~= want) then + if (event.self == nil) or (event.self.type ~= want) then return false end elseif name == "b" then - if (event.other == nil) or (event.other.kind ~= want) then + if (event.other == nil) or (event.other.type ~= want) then return false end elseif event[name] ~= want then return false end end - -- each on an event about an instance narrows it to that kind; on an event about nothing in - -- particular (a key, a room, the disc) it fans the rule out over every instance of the kind. - if rule.each and (event.self ~= nil) and (event.self.kind ~= rule.each) then + -- each on an event about an instance narrows it to that type; on an event about nothing in + -- particular (a key, a room, the disc) it fans the rule out over every instance of the type. + if rule.each and (event.self ~= nil) and (event.self.type ~= rule.each) then return false end if rule.room and ((AUTHOR_ROOM == nil) or (AUTHOR_ROOM.name ~= rule.room)) then @@ -3873,7 +3990,7 @@ local function buildRoom(room) room.saved = nil for _, entry in ipairs(from) do - make(entry.kind, entry.x, entry.y, entry.z, entry) + make(entry.type, entry.x, entry.y, entry.z, entry) end authorRoomNav() end @@ -3977,6 +4094,7 @@ function authorGoTo(name, instance, x, y, z, at) authorEvent("spawn", { self = spawned }) end end + authorWavesReset() authorEvent("roomStart", { room = name }) end @@ -4233,6 +4351,116 @@ function authorTrackBox(name) end +-- ===== Waves ================================================================================== +-- +-- A track of spawns: keys by time, by disc frame, or by a rail camera's stop, each making count +-- instances of a type, one every so many seconds, at an entity named in from -- at each of a +-- comma-separated list of them in turn -- or at the key's own point. A key begins once per +-- visit to the room. + +local waves = {} -- The waves under way: { key, made, wait }. +local started = {} -- The keys begun this visit, by key. + + +local function waveBegin(key) + started[key] = true + waves[#waves + 1] = { key = key, made = 0, wait = 0 } +end + + +-- Where a wave's next instance goes. +local function wavePlace(wave) + local from = wave.key.from + local names = {} + + if type(from) == "table" then + names = from + elseif type(from) == "string" then + for name in from:gmatch("[^,%s]+") do + names[#names + 1] = name + end + end + if #names > 0 then + local x, y, z = authorTargetPosition(names[(wave.made % #names) + 1]) + + if x then + return x, y, z + end + end + + return wave.key.x or 0, wave.key.y or 0, wave.key.z or 0 +end + + +-- Every track of spawns, the room's and the game's. +local function waveTracks() + local found = {} + + for _, list in ipairs({ AUTHOR_ROOM and AUTHOR_ROOM.tracks or {}, AUTHOR_GAME.tracks or {} }) do + for _, track in ipairs(list) do + if track.spawns then + found[#found + 1] = track + end + end + end + + return found +end + + +local function updateWaves(dt) + -- Keys by time or by frame begin when the track passes them. + for _, track in ipairs(waveTracks()) do + if track.key ~= "stop" then + local now = trackNow(track) + + for _, key in ipairs(track.spawns) do + if not started[key] and (type(key.at) == "number") and (now >= key.at) then + waveBegin(key) + end + end + end + end + -- Each wave under way makes its next instance when its wait is up. + for index = #waves, 1, -1 do + local wave = waves[index] + + wave.wait = wave.wait - dt + if wave.wait <= 0 then + local x, y, z = wavePlace(wave) + + authorSpawn(wave.key.type, x, y, z) + wave.made = wave.made + 1 + wave.wait = wave.key.every or 0 + if wave.made >= (wave.key.count or 1) then + table.remove(waves, index) + end + end + end +end + + +-- A rail camera reached a stop: the waves keyed by that stop begin. +function authorWavesAtStop(name) + for _, track in ipairs(waveTracks()) do + if track.key == "stop" then + for _, key in ipairs(track.spawns) do + if not started[key] and (key.at == name) then + waveBegin(key) + end + end + end + end +end + + +-- A room begun: its waves start over. +function authorWavesReset() + waves = {} + started = {} +end + + -- ===== Timers and the disc ==================================================================== local function updateTimers() @@ -4280,7 +4508,7 @@ end -- ===== Collisions ============================================================================= -- --- The rules say which kinds they care about; only those pairs are tested, and a pair that goes on +-- The rules say which types they care about; only those pairs are tested, and a pair that goes on -- touching is reported once. local touchingNow = {} @@ -4352,7 +4580,7 @@ function authorBegin(game) if game.size then overlaySetResolution(game.size.w, game.size.h) end - game.kinds = game.kinds or {} + game.types = game.types or {} game.rooms = game.rooms or { { name = "main", entities = {} } } game.disc = false for _, layer in ipairs(game.layers or {}) do @@ -4367,6 +4595,13 @@ function authorBegin(game) kind.begin(layer) end end + -- What is behind the overlay: the disc or the scene when the game has one, and a dark ground + -- of its own otherwise, so a flat game never plays over whatever video the engine was given. + if game.disc or AUTHOR_3D then + colorBackground(0, 0, 0, 0) + else + colorBackground(AUTHOR_GROUND.r, AUTHOR_GROUND.g, AUTHOR_GROUND.b, 255) + end if (game.players or 1) > 1 then mouseSetMode(MOUSE_MANY) end @@ -4383,6 +4618,7 @@ function authorFrame() lastTime = now updateTimers() updateDisc() + updateWaves(dt) for _, instance in ipairs(AUTHOR_LIVE) do if instance.alive then for _, b in ipairs(instance.behaviours) do @@ -4417,7 +4653,7 @@ function authorFrame() instance.order = at end table.sort(order, function(a, b) - local ay, by = authorY(a), authorY(b) + local ay, by = authorDepthY(a), authorDepthY(b) if ay == by then return a.order < b.order @@ -4563,7 +4799,23 @@ function authorKeyUp(keysym, scancode) end +-- In MODE_FULL the engine hands every key to onInputPressed as its keysym, on the same channel +-- as the switches, and four keysyms are switch numbers too: BACKSPACE is 8 (SWITCH_BUTTON3), +-- TAB 9 (SWITCH_COIN1), RETURN 13, and ESCAPE 27. The key is down at that moment and the switch +-- is not, which tells them apart. +local KEY_MASQUERADES = { [8] = "BACKSPACE", [9] = "TAB", [13] = "RETURN", [27] = "ESCAPE" } + +function authorSwitchIsKey(what) + local name = KEY_MASQUERADES[what] + + return (name ~= nil) and keyboardIsDown(SCANCODE[name].value) +end + + function authorSwitchDown(what) + if authorSwitchIsKey(what) then + return + end AUTHOR_SWITCHES[what] = true pressedNow[what] = true if not AUTHOR_CONTROLS then @@ -4593,6 +4845,9 @@ end function authorSwitchUp(what) + if authorSwitchIsKey(what) then + return + end AUTHOR_SWITCHES[what] = nil authorEvent("released", { switch = what }) end diff --git a/assets/Forge/AuthorCompile.singe b/assets/Forge/AuthorCompile.singe index 32d01bd3f..4da9cea0e 100644 --- a/assets/Forge/AuthorCompile.singe +++ b/assets/Forge/AuthorCompile.singe @@ -25,7 +25,7 @@ -- The compiler: a game description to a Singe game (FORGE.md). -- --- A description is data -- kinds, rooms, and rules -- and what comes out is a Lua script that +-- A description is data -- types, rooms, and rules -- and what comes out is a Lua script that -- calls the runtime in Author.singe. The rules become real functions: a condition is a Lua -- expression, an action a statement, and a rule with an action that takes time a coroutine. The -- description travels with the game so it can be opened again; nothing is lost in either @@ -80,6 +80,19 @@ local OPERATORS = { } +-- The keys of a table in name order, so anything written from one is written the same way each time. +local function sortedKeys(t) + local keys = {} + + for key in pairs(t or {}) do + keys[#keys + 1] = key + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + + return keys +end + + local function tokenize(text) local tokens = {} local at = 1 @@ -348,11 +361,18 @@ local function fragment(kind, value) end +-- A part's parameters as Lua. One the author left empty takes the manifest's default for it, +-- and one with neither becomes nil, which the checker has already pointed out. local function fragments(entry, item) local out = {} for key, kind in pairs(entry.params or {}) do - out[key] = fragment(kind, item[key]) + local value = item[key] + + if (value == nil) and entry.defaults then + value = entry.defaults[key] + end + out[key] = fragment(kind, value) or "nil" end return out @@ -472,7 +492,10 @@ end -- actions, compiled as a rule's are, with self and other absent. local function emitDialogue(out, name, dialogue) out[#out + 1] = INDENT .. string.format("[%q] = { start = %q, nodes = {", name, dialogue.start or "start") - for nodeName, node in pairs(dialogue.nodes or {}) do + -- Nodes in name order, so the same description compiles to the same text every time. + for _, nodeName in ipairs(sortedKeys(dialogue.nodes or {})) do + local node = dialogue.nodes[nodeName] + out[#out + 1] = INDENT .. INDENT .. string.format("[%q] = { name = %q, who = %s, text = %s, seconds = %s, next = %s, choices = {", nodeName, nodeName, node.who and string.format("%q", node.who) or "nil", @@ -632,13 +655,13 @@ end -- ===== Checking =============================================================================== -- --- What the compiler can tell an author before the game runs: a kind that is not declared, a +-- What the compiler can tell an author before the game runs: a type that is not declared, a -- condition the manifest does not have, an expression that does not parse. Returns a list of -- messages, empty when the description is sound. function authorCheck(game) local problems = {} - local kinds = game.kinds or {} + local types = game.types or {} local function problem(text) problems[#problems + 1] = text @@ -658,8 +681,10 @@ function authorCheck(game) if not ok then problem(where .. ": " .. tostring(err)) end - elseif (kind == "kind") and (item[key] ~= nil) and (kinds[item[key]] == nil) then - problem(where .. ": no kind called '" .. tostring(item[key]) .. "'") + elseif (kind == "type") and (item[key] ~= nil) and (types[item[key]] == nil) then + problem(where .. ": no type called '" .. tostring(item[key]) .. "'") + elseif (item[key] == nil) and (kind ~= "entity") and ((entry.defaults or {})[key] == nil) and not (entry.optional or {})[key] then + problem(where .. ": " .. key .. " is empty") end end end @@ -676,32 +701,47 @@ function authorCheck(game) end end - for name, kind in pairs(kinds) do - local look = kind.look and AUTHOR.looks[kind.look.kind] or nil + for name, entityType in pairs(types) do + local look = entityType.look and AUTHOR.looks[entityType.look.kind] or nil - if kind.look and (look == nil) then - problem("kind " .. name .. ": no look called '" .. tostring(kind.look.kind) .. "'") + if entityType.look and (look == nil) then + problem("type " .. name .. ": no look called '" .. tostring(entityType.look.kind) .. "'") elseif look then - checkFields(kind.look, look.params, { kind = true }, "kind " .. name .. "'s look") + checkFields(entityType.look, look.params, { kind = true }, "type " .. name .. "'s look") end - for _, b in ipairs(kind.behaviours or {}) do + for _, b in ipairs(entityType.behaviours or {}) do local behaviour = AUTHOR.behaviours[b.kind] if behaviour == nil then - problem("kind " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'") + problem("type " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'") else - checkFields(b, behaviour.params, { kind = true }, "kind " .. name .. "'s " .. b.kind) + checkFields(b, behaviour.params, { kind = true }, "type " .. name .. "'s " .. b.kind) end end - checkFields(kind, { look = true, vars = true, behaviours = true }, {}, "kind " .. name) + checkFields(entityType, { look = true, vars = true, behaviours = true }, {}, "type " .. name) end if #(game.rooms or {}) == 0 then problem("the game has no rooms") end for _, room in ipairs(game.rooms or {}) do for _, entry in ipairs(room.entities or {}) do - if kinds[entry.kind] == nil then - problem("room " .. tostring(room.name) .. ": no kind called '" .. tostring(entry.kind) .. "'") + if types[entry.type] == nil then + problem("room " .. tostring(room.name) .. ": no type called '" .. tostring(entry.type) .. "'") + end + end + -- A waves track's keys name a type, and are at a moment -- or at a stop, when keyed by one. + for _, track in ipairs(room.tracks or {}) do + for index, key in ipairs(track.spawns or {}) do + local where = "room " .. tostring(room.name) .. ", track " .. tostring(track.name) .. ", wave " .. index + + if types[key.type] == nil then + problem(where .. ": no type called '" .. tostring(key.type) .. "'") + end + if (track.key == "stop") and (type(key.at) ~= "string") then + problem(where .. ": at should name a stop") + elseif (track.key ~= "stop") and (type(key.at) ~= "number") then + problem(where .. ": at should be a number") + end end end end @@ -713,8 +753,8 @@ function authorCheck(game) elseif AUTHOR.events[rule.on or "frame"] then checkFields(rule, AUTHOR.events[rule.on or "frame"].filter, { note = true, on = true, each = true, room = true, when = true, act = true, controls = true, interrupt = true }, where) end - if rule.each and (kinds[rule.each] == nil) then - problem(where .. ": no kind called '" .. tostring(rule.each) .. "'") + if rule.each and (types[rule.each] == nil) then + problem(where .. ": no type called '" .. tostring(rule.each) .. "'") end checkItems(AUTHOR.conditions, rule.when, "condition", where) checkItems(AUTHOR.conditions, rule.when and rule.when.any, "condition", where) @@ -755,6 +795,7 @@ function authorCompile(game) -- chunk actually running, either way. out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")' out[#out + 1] = 'dofile(here .. "Author.singe")' + out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first." out[#out + 1] = "" out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code." out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")" @@ -770,21 +811,22 @@ function authorCompile(game) if game.dialogues then out[#out + 1] = "-- The dialogues: nodes of a line and choices, each choice's condition and actions as code." out[#out + 1] = "authorDialogues({" - for name, dialogue in pairs(game.dialogues) do - emitDialogue(out, name, dialogue) + for _, name in ipairs(sortedKeys(game.dialogues)) do + emitDialogue(out, name, game.dialogues[name]) end out[#out + 1] = "})" out[#out + 1] = "" end - out[#out + 1] = "onKeyPressed = authorKeyDown" - out[#out + 1] = "onKeyReleased = authorKeyUp" - out[#out + 1] = "onInputPressed = authorSwitchDown" - out[#out + 1] = "onInputReleased = authorSwitchUp" - out[#out + 1] = "onMouseMoved = authorMouseMoved" - out[#out + 1] = "onCollision = authorCollision" - out[#out + 1] = "onTrigger = authorTrigger" - out[#out + 1] = "onNavArrived = authorNavArrived" - out[#out + 1] = "onMidiMessage = authorMidi" + out[#out + 1] = "onKeyPressed = authorKeyDown" + out[#out + 1] = "onKeyReleased = authorKeyUp" + out[#out + 1] = "onInputPressed = authorSwitchDown" + out[#out + 1] = "onInputReleased = authorSwitchUp" + out[#out + 1] = "onMouseMoved = authorMouseMoved" + out[#out + 1] = "onCollision = authorCollision" + out[#out + 1] = "onTrigger = authorTrigger" + out[#out + 1] = "onNavArrived = authorNavArrived" + out[#out + 1] = "onMidiMessage = authorMidi" + out[#out + 1] = "onSoundCompleted = authorSoundDone" out[#out + 1] = "" out[#out + 1] = "function onOverlayUpdate()" out[#out + 1] = INDENT .. "authorFrame()" diff --git a/assets/Forge/Forge.rml b/assets/Forge/Forge.rml index ebba78de5..41f4f83ff 100644 --- a/assets/Forge/Forge.rml +++ b/assets/Forge/Forge.rml @@ -12,8 +12,21 @@ forge diff --git a/assets/Forge/Forge.singe b/assets/Forge/Forge.singe index ea98da368..b505f468f 100644 --- a/assets/Forge/Forge.singe +++ b/assets/Forge/Forge.singe @@ -34,7 +34,7 @@ -- and a set of rectangles. They compose because the engine offers a button to the GUI first and -- passes on what it did not use, while pointer motion is never consumed at all. -- --- The panel shows one of four lists -- the room's entities, the kinds, the rules, the room's tracks +-- The panel shows one of five lists -- the room's entities, the types, the rules, the room's tracks -- -- and TAB cycles them. Whatever is selected is edited the same way: ENTER walks its fields and -- each is typed. The fields come from the manifest in Author.singe, so a new look, behaviour, -- condition, action, or event is editable the moment it is declared. @@ -54,9 +54,9 @@ FORGE = { game = nil, -- The description being edited. path = nil, room = 1, -- Index into game.rooms. - mode = "entities", -- entities, kinds, rules, or tracks. + mode = "entities", -- entities, types, rules, tracks, or dialogues. selected = nil, -- Index into the room's entities. - kindName = nil, -- The selected kind. + typeName = nil, -- The selected type. rule = 1, -- Selected rule, part = 0, -- and the selected condition or action within it; 0 is the rule itself. track = 1, -- Selected track in the room, @@ -76,6 +76,8 @@ FORGE = { undo = {}, -- Copies of the description, newest last. redo = {}, -- What undo took away, newest last; any other change empties it. sprites = {}, -- Sprite looks loaded for drawing, by file name; false when missing. + chooser = { files = {}, at = 1 }, -- What the chooser lists, and which is lit. + stash = {}, -- Behaviours and layers typed away, by owner, so typing them back keeps their values. sheets = {}, -- The same files cut into frames, by file and frame count, for turned sheets. video = nil, -- The disc layer's file, loaded so the timeline can scrub it. videoFile = nil, @@ -95,39 +97,34 @@ local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just local TAB_H = 54 local TAB_Y = 10 local UNDO_MAX = 40 -- Steps kept. A description is small; forty copies are nothing. -local NEW_W = 40 -- The box a new kind wears until it is given a look. +local NEW_W = 40 -- The box a new type wears until it is given a look. local NEW_H = 40 local STRIP_H = 34 -- The timeline along the bottom of the canvas. local STRIP_PAD = 8 -local MODES = { "entities", "kinds", "rules", "tracks", "dialogues" } +local MODES = { "entities", "types", "rules", "tracks", "dialogues" } local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true } local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks. +local FILE_NONE = "(none)" -- The file picker's first entry: no file. local FRAME_STEP = 1 local FRAME_LEAP = 10 local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it. local GIZMO_LEN = 1.5 -- World units each gizmo arm reaches from the selected entity. local GIZMO_GRAB = 10 -- Pixels a press may be from an arm's tip to take it. local TURN_REACH = 18 -- Pixels the 2D turn handle stands off the selected entity's box. -local THUMB = 24 -- The kinds panel's thumbnails fit a square this many pixels across. +local THUMB = 24 -- The types panel's thumbnails fit a square this many pixels across. local ORBIT_STEP = 10 -- Degrees the editor camera turns per key. +local PICK_ROWS = 16 -- Rows a picker shows at once: the scancodes are two hundred and more. +local DROP_ROWS = 6 -- Rows a field's dropdown shows, floating under its row. local ZOOM_STEP = 1.15 -- How much closer or farther per key. local sceneBegun = false -- The 3D scene is switched on once per run of the editor. -FORGE_DEFAULTS = { -- What a new parameter starts as, by the manifest's type. - number = 0, - string = "", - boolean = true, - entity = "self", - kind = "", - scancode = "SPACE", +FORGE_DEFAULTS = { -- What a new parameter starts as, by the manifest's type: only what + entity = "self", -- must be there to mean anything. A number, a string, a file, a room, + scancode = "SPACE", -- or a flag left empty takes its own default when the game is built. switch = "SWITCH_BUTTON1", expression = '""', lua = "-- your Lua here", - file = "", - state = "idle", - track = "", - room = "", - table = nil + state = "idle" } @@ -144,10 +141,24 @@ end -- A sprite look's image, loaded once and kept, so the canvas can show it. A file that is not -- there answers nil rather than ending the editor the way spriteLoad would: the entity is drawn -- as a box until the name is right. +-- Where a file the description names is, for the editor: beside the description first (a +-- released game carries its files beside itself), then as the engine sees it from the games +-- directory, which is how a description in the data directory names the kit (Forge/kit/hero.png). +function forgeFile(name) + local beside = (FORGE.path or ""):match("^(.*[/\\])") + + if beside and not name:match("^[/\\]") and not name:match("^%a:") and (lfs.attributes(beside .. name, "mode") == "file") then + return beside .. name + end + + return name +end + + local function spriteFor(file) if FORGE.sprites[file] == nil then - if (file ~= "") and (lfs.attributes(file, "mode") == "file") then - FORGE.sprites[file] = spriteLoad(file) + if (file ~= "") and (lfs.attributes(forgeFile(file), "mode") == "file") then + FORGE.sprites[file] = spriteLoad(forgeFile(file)) else FORGE.sprites[file] = false end @@ -162,7 +173,7 @@ local function sheetFor(file, frames) local key = file .. "#" .. frames if FORGE.sheets[key] == nil then - FORGE.sheets[key] = spriteFor(file) and spriteLoadFrames(frames, file) or false + FORGE.sheets[key] = spriteFor(file) and spriteLoadFrames(frames, forgeFile(file)) or false end return FORGE.sheets[key] or nil @@ -197,9 +208,11 @@ local function copyOf(value) end --- The behaviour of a kind a kind carries, if any. -local function behaviourOf(kind, name) - for _, b in ipairs(kind.behaviours or {}) do +-- The behaviour of a type that it carries, if any. +local fillDefaults + +local function behaviourOf(entityType, name) + for _, b in ipairs(entityType.behaviours or {}) do if b.kind == name then return b end @@ -209,6 +222,153 @@ local function behaviourOf(kind, name) end +-- A list of kinded entries -- a type's behaviours, the game's layers -- rebuilt from a typed +-- comma list. One already there keeps its values; one typed away and typed back gets them +-- back too, since the setter runs on every keystroke and "pl" has no platformer in it yet; a +-- new one starts with the manifest's defaults; a name the manifest lacks is refused. stash is +-- where the dropped ones wait, per owner. +local function listFrom(text, list, manifest, stash, what) + local made = {} + + for _, entry in ipairs(list or {}) do + stash[entry.kind] = entry + end + for want in string.gmatch(tostring(text), "[^,%s]+") do + local entry = stash[want] or { kind = want } + + if manifest[want] ~= nil then + fillDefaults(entry, manifest[want].params, true) + made[#made + 1] = entry + else + FORGE.message = "no " .. what .. " called " .. want + end + end + + return made +end + + +-- The names in a kinded list, as one line. +local function listText(list) + local names = {} + + for _, entry in ipairs(list or {}) do + names[#names + 1] = entry.kind + end + + return table.concat(names, ", ") +end + + +-- A table parameter as text and back. "r=80, g=80, b=100" is a map; "100,-20; 300,-20" a list of +-- points (x, y, and z when given); "name=head, bone=b_Head_05, w=0.5; name=body, ..." a list +-- of records, one per semicolon; "look=look|examine, take=take|get" a map whose values are +-- lists; "the; a; an" a list of words. Enough for a light, a fog, a spawner's points, a +-- target's zones, a parser's words, and a hud's bind; the description can hold anything, and +-- what this cannot say it leaves alone. +local function tableText(t) + if type(t) ~= "table" then + return t + end + if t[1] ~= nil then + local items = {} + + for _, item in ipairs(t) do + if type(item) ~= "table" then + items[#items + 1] = tostring(item) + elseif (item.x ~= nil) and (item.name == nil) then + items[#items + 1] = tostring(item.x) .. "," .. tostring(item.y or 0) .. ((item.z ~= nil) and ("," .. tostring(item.z)) or "") + elseif item[1] ~= nil then + items[#items + 1] = tostring(item[1]) .. "," .. tostring(item[2] or 0) .. ((item[3] ~= nil) and ("," .. tostring(item[3])) or "") + else + items[#items + 1] = tableText(item) + end + end + return table.concat(items, "; ") + end + local parts = {} + + for _, key in ipairs(sortedKeys(t)) do + local value = t[key] + + if type(value) == "table" then + local words = {} + + for _, word in ipairs(value) do + words[#words + 1] = tostring(word) + end + value = table.concat(words, "|") + end + parts[#parts + 1] = key .. "=" .. tostring(value) + end + + return table.concat(parts, ", ") +end + + +-- A typed value: a number, true, false, a list of words split on |, or the text itself. +local function typedWord(value) + value = value:gsub("^%s+", ""):gsub("%s+$", "") + if value:find("|", 1, true) then + local words = {} + + for word in string.gmatch(value, "[^|]+") do + words[#words + 1] = typedWord(word) + end + return words + end + + return tonumber(value) or ((value == "true") and true) or ((value == "false") and false) or value +end + + +-- One "k=v, k=v" record as a map. +local function recordFrom(text) + local map = {} + + for name, value in string.gmatch(text, "([%w_]+)%s*=%s*([^,]*)") do + map[name] = typedWord(value) + end + + return map +end + + +local function tableFrom(text) + text = tostring(text) + -- One record with no semicolons is a map: a colour, a bind, a parser's words. + if text:find("=") and not text:find(";", 1, true) then + return recordFrom(text) + end + local items = {} + + for item in string.gmatch(text, "[^;]+") do + if item:find("=") then + items[#items + 1] = recordFrom(item) + else + local numbers = {} + + for number in string.gmatch(item, "-?[%d.]+") do + numbers[#numbers + 1] = tonumber(number) + end + if #numbers >= 2 then + items[#items + 1] = { x = numbers[1], y = numbers[2], z = numbers[3] } + elseif #numbers == 1 then + items[#items + 1] = numbers[1] + else + local word = item:match("^%s*(.-)%s*$") + + if word ~= "" then + items[#items + 1] = word + end + end + end + end + + return items +end + + -- Text on its way into the document, with RML's special characters made harmless. A rule's note -- and a Lua action's code both come from the author and both can contain them. local function escape(text) @@ -229,20 +389,20 @@ local function rooted(file) return file end - return lfs.currentdir() .. "/" .. file + return lfs.currentdir() .. "/" .. forgeFile(file) end --- What a kind looks like, small, for its row in the panel: the sprite itself (one frame of a +-- What a type looks like, small, for its row in the panel: the sprite itself (one frame of a -- sheet), or a swatch of the box's colour. local function thumbnail(look) if look.kind == "sprite" then local sprite = spriteFor(look.file or "") if sprite ~= nil then - local w, h = spriteGetWidth(sprite) / (look.frames or 1), spriteGetHeight(sprite) - local fit = THUMB / math.max(w, h) - local rect = ((look.frames or 1) > 1) and string.format(' rect="0 0 %d %d"', w, h) or "" + local w, h = spriteGetWidth(sprite) / math.max(1, look.frames or 1), spriteGetHeight(sprite) + local fit = THUMB / math.max(1, w, h) + local rect = ((look.frames or 1) > 1) and string.format(' rect="0 0 %d %d"', math.floor(w), h) or "" return string.format('', escape(rooted(look.file)), rect, math.floor(w * fit + 0.5), math.floor(h * fit + 0.5)) end @@ -258,11 +418,11 @@ end -- ===== Entities on the canvas ================================================================= --- What an entity looks like: its kind's look, with anything the entry overrides. +-- What an entity looks like: its type's look, with anything the entry overrides. function forgeLook(entry) - local kind = FORGE.game.kinds[entry.kind] + local entityType = FORGE.game.types[entry.type] - return (kind and kind.look) or { kind = "none" } + return (entityType and entityType.look) or { kind = "none" } end @@ -277,7 +437,7 @@ function forgeBounds(entry) local sprite = spriteFor(look.file or "") if sprite ~= nil then - w = spriteGetWidth(sprite) / (look.frames or 1) + w = spriteGetWidth(sprite) / math.max(1, look.frames or 1) h = spriteGetHeight(sprite) end elseif look.kind == "text" then @@ -286,6 +446,14 @@ function forgeBounds(entry) w = w * (entry.scale or 1) h = h * (entry.scale or 1) + -- A line of text hangs from its point, its top left, as the game draws it; a look that + -- stands on its point (anchor feet) is drawn above it. + if look.kind == "text" then + return entry.x or 0, entry.y or 0, w, h + end + if look.anchor == "feet" then + return (entry.x or 0) - w / 2, (entry.y or 0) - h, w, h + end return (entry.x or 0) - w / 2, (entry.y or 0) - h / 2, w, h end @@ -317,14 +485,101 @@ end -- ===== The panel ============================================================================== -- The fields of whatever is selected, one row each, with the one ENTER is on marked. This is the --- detail panel for an entity, a kind, a rule, and a key alike: the same rows, the same keys. +-- detail panel for an entity, a type, a rule, and a key alike: the same rows, the same keys. +-- Every row the panel lists is a div with an id, and a click on it goes to forgeRowClick with +-- that id: the ids made while a list is built are wired once the list is on screen, since +-- setting inner_rml makes new elements. +local pendingRows = {} + +local function row(id, class, inner) + pendingRows[#pendingRows + 1] = id + + return string.format('
%s
', id, class, inner) +end + + +local function wireRows() + for _, id in ipairs(pendingRows) do + local rowId = id + + guiSetHandler(FORGE.gui, FORGE.document, rowId, "click", function() + forgeRowClick(rowId) + end) + end + pendingRows = {} +end + + +-- A field's name as the panel shows it: an abbreviation in capitals, the rest as it is. +local function fieldLabel(name) + return (name:gsub("^id$", "ID"):gsub("%.id$", ".ID")) +end + + +-- The picker's rows: a window of PICK_ROWS around the one chosen, with what lies past it said, +-- and the line typed to narrow it. class is the row's style: a list taking the panel over +-- wears the panel's rows, a field's dropdown its own. +local function pickerRows(class, window) + local rows = {} + local shown = forgePickItems() + local from = math.max(1, math.min(FORGE.picker.at - math.floor(window / 2), #shown - window + 1)) + local to = math.min(#shown, from + window - 1) + + if from > 1 then + rows[#rows + 1] = '
' .. (from - 1) .. " more above
" + end + for at = from, to do + rows[#rows + 1] = row("p" .. at, (at == FORGE.picker.at) and (class .. " selected") or class, escape(shown[at].name)) + end + if to < #shown then + rows[#rows + 1] = '
' .. (#shown - to) .. " more below
" + end + rows[#rows + 1] = '
' .. (FORGE.picker.free and "type to narrow, or a new one: " or "type to narrow: ") .. escape(FORGE.picker.filter) .. "_
" + + return table.concat(rows), shown +end + + +-- A field's list unfolds under its own row, as a dropdown does, with the help of the row chosen. +local function dropdown() + local rows, shown = pickerRows("choice", DROP_ROWS) + local help = (shown[FORGE.picker.at] or {}).help + + -- Hidden until forgePlace has measured it against the panel: it opens upward when there is + -- no room below. + FORGE.placing = FORGE.frame or 0 + + return '" +end + + local function fieldRows(fields) local rows = {} - for _, name in ipairs(fields.names) do + for index, name in ipairs(fields.names) do local class = (name == FORGE.lastField) and "part here" or "part" + local shown = tostring(fields.get(name)) - rows[#rows + 1] = string.format('
%s %s
', class, escape(name), escape(tostring(fields.get(name)))) + -- The field being typed shows its text in its own row: selected whole, so typing + -- replaces it, or with the caret where the arrows put it. One with a list has it + -- unfolded under the row. + if FORGE.editing and (FORGE.editing.key == name) then + local editing = FORGE.editing + + if FORGE.picker and FORGE.picker.field then + FORGE.dropRow = "f" .. index + rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. escape(shown)) .. dropdown() + else + if editing.selected then + shown = '' .. escape(editing.text) .. "" + else + shown = escape(editing.text:sub(1, editing.cursor)) .. "|" .. escape(editing.text:sub(editing.cursor + 1)) + end + rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. shown) + end + else + rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. escape(shown)) + end end return table.concat(rows) @@ -338,6 +593,7 @@ local function detail(fields, head) rows = rows .. fieldRows(fields) end element("detail").inner_rml = rows .. '
' .. escape(FORGE.message) .. "
" + wireRows() end @@ -348,17 +604,12 @@ local function refreshEntities() for index, entry in ipairs(room.entities) do local class = (index == FORGE.selected) and "row selected" or "row" - rows[#rows + 1] = string.format('
%s
', index, class, escape((entry.id or "?") .. " (" .. tostring(entry.kind) .. ")")) + rows[#rows + 1] = row("e" .. index, class, escape((entry.id or "?") .. " (" .. tostring(entry.type) .. ")")) end if #rows == 0 then rows[1] = '
no entities here: A adds one
' end element("list").inner_rml = table.concat(rows) - for index = 1, #room.entities do - guiSetHandler(FORGE.gui, FORGE.document, "e" .. index, "click", function() - forgeSelect(index) - end) - end detail(forgeFields(), string.format('
room %d of %d: %s
', FORGE.room, #FORGE.game.rooms, escape(room.name))) end @@ -366,13 +617,13 @@ end local function refreshKinds() local rows = {} - for _, name in ipairs(sortedKeys(FORGE.game.kinds)) do - local class = (name == FORGE.kindName) and "row selected" or "row" + for index, name in ipairs(sortedKeys(FORGE.game.types)) do + local class = (name == FORGE.typeName) and "row selected" or "row" - rows[#rows + 1] = string.format('
%s%s
', class, thumbnail(FORGE.game.kinds[name].look or {}), escape(name)) + rows[#rows + 1] = row("t" .. index, class, thumbnail(FORGE.game.types[name].look or {}) .. escape(name)) end if #rows == 0 then - rows[1] = '
no kinds yet: A adds one
' + rows[1] = '
no types yet: A adds one
' end element("list").inner_rml = table.concat(rows) detail(forgeFields()) @@ -387,15 +638,15 @@ local function refreshRules() for index, rule in ipairs(FORGE.game.rules) do local class = (index == FORGE.rule) and "row selected" or "row" - rows[#rows + 1] = string.format('
%d. %s
', class, index, escape(rule.note or "rule")) + rows[#rows + 1] = row("r" .. index, class, index .. ". " .. escape(rule.note or "rule")) if index == FORGE.rule then local parts = forgeParts(rule) - rows[#rows + 1] = string.format('
on %s%s
', escape(rule.on or "frame"), rule.each and (" each " .. escape(rule.each)) or "") + rows[#rows + 1] = row("r" .. index .. "p0", "part", "on " .. escape(rule.on or "frame") .. (rule.each and (" each " .. escape(rule.each)) or "")) for at, part in ipairs(parts) do local mark = (at == FORGE.part) and "part here" or "part" - rows[#rows + 1] = string.format('
%s %s
', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item))) + rows[#rows + 1] = row("r" .. index .. "p" .. at, mark, ((part.kind == "when") and "when " or "then ") .. escape(forgePartText(part.item))) end if #parts == 0 then rows[#rows + 1] = '
(C adds a condition, T an action)
' @@ -417,15 +668,17 @@ local function refreshTracks() for index, track in ipairs(tracks) do local class = (index == FORGE.track) and "row selected" or "row" - rows[#rows + 1] = string.format('
%s (by %s)
', class, escape(track.name or "?"), escape(track.key or "frame")) + rows[#rows + 1] = row("k" .. index, class, escape(track.name or "?") .. (track.spawns and " (waves by " or " (by ") .. escape(track.key or "frame") .. ")") if index == FORGE.track then for at, key in ipairs(forgeTrackKeys(track)) do local mark = (at == FORGE.key) and "part here" or "part" - if track.points then - rows[#rows + 1] = string.format('
at %s: %s, %s, %s%s
', mark, tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.z), key.stop and (" stop " .. escape(key.stop)) or "") + if track.spawns then + rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s x %s%s%s", tostring(key.at), tostring(key.count or 1), escape(tostring(key.type)), key.from and (" from " .. escape(tostring(key.from))) or "", key.every and (" every " .. tostring(key.every)) or "")) + elseif track.points then + rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s, %s, %s%s", tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.z), key.stop and (" stop " .. escape(key.stop)) or "")) else - rows[#rows + 1] = string.format('
at %s: %s,%s %sx%s
', mark, tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.w), tostring(key.h)) + rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s,%s %sx%s", tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.w), tostring(key.h))) end end if #forgeTrackKeys(track) == 0 then @@ -444,16 +697,11 @@ end -- A picker takes the list over while it is up: the manifest is what is being chosen from, and -- the panel is too narrow to show it beside anything else. local function refreshPicker() - local rows = {} - local shown = forgePickItems() + local rows, shown = pickerRows("row", PICK_ROWS) - for at, item in ipairs(shown) do - local class = (at == FORGE.picker.at) and "row selected" or "row" - - rows[#rows + 1] = string.format('
%s
', class, escape(item.name)) - end - element("list").inner_rml = table.concat(rows) - element("detail").inner_rml = escape(FORGE.picker.title) .. "
" .. escape((shown[FORGE.picker.at] or {}).help or "") .. '
type to narrow: ' .. escape(FORGE.picker.filter) .. '_
UP / DOWN choose, ENTER takes it, ESC leaves it
' + element("list").inner_rml = rows + element("detail").inner_rml = escape(FORGE.picker.title) .. "
" .. escape((shown[FORGE.picker.at] or {}).help or "") .. '
UP / DOWN choose, ENTER or a click takes it, ESC leaves it
' + wireRows() end @@ -489,6 +737,8 @@ function forgeDialogueRows() end +local dialogueSelect + local function dialogueRowSelected(row) if row.level == "dialogue" then return (row.dialogue == FORGE.dialogue) and (FORGE.dnode == nil) @@ -505,13 +755,13 @@ end local function refreshDialogues() local rows = {} - for _, row in ipairs(forgeDialogueRows()) do - local class = ({ dialogue = "row", node = "part", choice = "part", act = "part" })[row.level] + for index, item in ipairs(forgeDialogueRows()) do + local class = ({ dialogue = "row", node = "part", choice = "part", act = "part" })[item.level] - if dialogueRowSelected(row) then - class = (row.level == "dialogue") and "row selected" or "part here" + if dialogueRowSelected(item) then + class = (item.level == "dialogue") and "row selected" or "part here" end - rows[#rows + 1] = string.format('
%s
', class, escape(row.label)) + rows[#rows + 1] = row("d" .. index, class, escape(item.label)) end if #rows == 0 then rows[1] = '
no dialogues yet: A adds one
' @@ -522,7 +772,7 @@ end -- Moves the dialogue selection to a row of the outline. -local function dialogueSelect(row) +dialogueSelect = function(row) FORGE.dialogue = row.dialogue FORGE.dnode = row.node FORGE.dchoice = row.choice or 0 @@ -751,11 +1001,12 @@ function forgeRefresh() if FORGE.gui == nil then return end + FORGE.placing = FORGE.frame or 0 -- Placed on the first draw after this frame. element("title").inner_rml = escape(FORGE.game.title or "Untitled") .. (FORGE.dirty and " *" or "") element("grip").inner_rml = "≡ " .. FORGE.mode:sub(1, 1):upper() .. FORGE.mode:sub(2) - if FORGE.picker ~= nil then + if (FORGE.picker ~= nil) and not FORGE.picker.field then refreshPicker() - elseif FORGE.mode == "kinds" then + elseif FORGE.mode == "types" then refreshKinds() elseif FORGE.mode == "rules" then refreshRules() @@ -787,8 +1038,8 @@ local function loadVideo() FORGE.video = nil end FORGE.videoFile = file - if file and (lfs.attributes(file, "mode") == "file") then - FORGE.video = videoLoad(file) + if file and (lfs.attributes(forgeFile(file), "mode") == "file") then + FORGE.video = videoLoad(forgeFile(file)) videoPause(FORGE.video) videoSeek(FORGE.video, FORGE.cursor) end @@ -802,7 +1053,7 @@ function forgeBegin(path) if FORGE.game == nil then return false end - FORGE.game.kinds = FORGE.game.kinds or {} + FORGE.game.types = FORGE.game.types or {} FORGE.game.rules = FORGE.game.rules or {} FORGE.game.layers = FORGE.game.layers or {} FORGE.game.rooms = FORGE.game.rooms or {} @@ -816,7 +1067,7 @@ function forgeBegin(path) FORGE.room = 1 FORGE.mode = "entities" FORGE.selected = nil - FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] + FORGE.typeName = sortedKeys(FORGE.game.types)[1] FORGE.dialogue = sortedKeys(FORGE.game.dialogues or {})[1] FORGE.dnode = nil FORGE.dchoice = 0 @@ -845,9 +1096,13 @@ function forgeBegin(path) end) guiSetHandler(FORGE.gui, FORGE.document, "grip", "mouseup", function() FORGE.panelDrag = nil - FORGE.message = "panel at " .. FORGE.panelX forgeRefresh() end) + -- The game's name at the top is the way back to the game's own form: nothing selected. + guiSetHandler(FORGE.gui, FORGE.document, "title", "click", function() + forgeMode("entities") + forgeSelect(nil) + end) loadVideo() forgeSceneBegin() forgePanelTo(0) @@ -973,16 +1228,34 @@ function forgeExport(folder, name) return nil end + -- Every file the description names goes with it, at the same relative name, which is where + -- the runtime looks first; the description itself is unchanged by the trip. + for _, name in ipairs(forgeFilesNamed(FORGE.game)) do + if not forgeCopyInto(name, folder) then + FORGE.message = "could not copy " .. name + forgeRefresh() + return nil + end + end + -- The description travels with the game, so the release can be opened and edited again. authorSave(FORGE.game, folder .. "/" .. script .. ".game") - -- A games.dat so the menu lists it, written from what the description already knows. 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. + -- A games.dat so the menu lists it, written from what the description already knows. Names + -- in it are as the engine sees them from the games directory, the folder's own name first, + -- which is how a packed copy addresses its files too. A game over a disc names its video in + -- the description, and the copy made above is what the entry names; anything else plays over + -- the menu's, since the engine wants one and the game draws over all of it anyway. + local base = folder:match("([^/\\]+)[/\\]*$") or folder + local video = forgeVideo() + + if not video:match("^Singe[/\\]") and not video:match("^[/\\]") and not video:match("^%a:") then + video = base .. "/" .. video + end dat = io.open(folder .. "/games.dat", "w") if dat ~= nil then dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n", - title, folder .. "/" .. script .. ".singe", forgeVideo(), overlayGetWidth(), overlayGetHeight())) + title, base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight())) dat:close() end @@ -993,6 +1266,87 @@ function forgeExport(folder, name) end +-- Where X releases to: a folder in the data directory named for the game, which is also what +-- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the +-- folder and its name. +function forgeReleaseFolder() + local stem = string.gsub(FORGE.game.title or "Untitled", "[^%w]", "") + + return singeGetDataPath() .. ((stem ~= "") and stem or "Game") +end + + +-- Every file a description names, by the manifest's word for what takes one: layers, looks, +-- behaviours, and actions with a "file" parameter, wherever they occur (rules, and the choices +-- of dialogues). The engine's own files (Singe/...) and absolute paths are not the game's to carry. +function forgeFilesNamed(game) + local seen = {} + local found = {} + + local function take(name) + if (type(name) == "string") and (name ~= "") and not seen[name] and not name:match("^Singe[/\\]") and not name:match("^[/\\]") and not name:match("^%a:") then + seen[name] = true + found[#found + 1] = name + end + end + + local function fromParams(entry, params) + for key, kind in pairs(params or {}) do + if kind == "file" then + take(entry[key]) + end + end + end + + local function fromActs(acts) + for _, act in ipairs(acts or {}) do + fromParams(act, (AUTHOR.actions[act[1]] or {}).params) + end + end + + for _, layer in ipairs(game.layers or {}) do + fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params) + end + for _, entityType in pairs(game.types or {}) do + if entityType.look then + fromParams(entityType.look, (AUTHOR.looks[entityType.look.kind] or {}).params) + end + for _, behaviour in ipairs(entityType.behaviours or {}) do + fromParams(behaviour, (AUTHOR.behaviours[behaviour.kind] or {}).params) + end + end + for _, rule in ipairs(game.rules or {}) do + fromActs(rule.act) + end + for _, dialogue in pairs(game.dialogues or {}) do + for _, node in ipairs(dialogue.nodes or dialogue) do + if type(node) == "table" then + fromActs(node.act) + for _, choice in ipairs(node.choices or {}) do + fromActs(choice.act) + end + end + end + end + table.sort(found) + + return found +end + + +-- Copies a named file into a folder at the same relative name, making the directories on the way. +function forgeCopyInto(name, folder) + local at = folder + + for part in name:gmatch("([^/\\]+)[/\\]") do + at = at .. "/" .. part + lfs.mkdir(at) + end + + return authorCopy(forgeFile(name), folder .. "/" .. name) +end + + -- Compiles what is on screen and hands back the path, so the caller can dofile it and play. The -- runtime goes beside it, because that is where the compiled game looks for it. What the checker -- has to say goes to the console and the panel. @@ -1036,7 +1390,7 @@ function forgePress(x, y) -- 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 + if not FORGE.panelHidden and collidePointRect(x, y, tx, ty, tw, th) then forgePanelGrab(x) return end @@ -1096,6 +1450,9 @@ function forgePress(x, y) FORGE.grabY = y - entry.y FORGE.mode = "entities" forgeSelect(FORGE.held) + elseif FORGE.mode == "entities" then + -- A press on nothing selects nothing, which is the game's own form. + forgeSelect(nil) end end @@ -1106,9 +1463,92 @@ function forgeRelease() end +-- A click on a row of the panel, by the row's id: e3 an entity, t2 a type, r1 a rule and r1p2 +-- its second part, k1 a track and k1y2 its second key, d4 the fourth row of the dialogue outline, +-- p3 the third thing a picker offers, f2 the second field of the form. A row selects what it +-- names, as the arrows would; a field starts typing it, as ENTER would after walking to it. +function forgeRowClick(id) + local letter, first, second = id:match("^(%a)(%d+)%a?(%d*)$") + local room = forgeRoom() + + first = tonumber(first) + second = tonumber(second) + if letter == "e" then + FORGE.mode = "entities" + forgeSelect(first) + elseif letter == "t" then + FORGE.typeName = sortedKeys(FORGE.game.types)[first] or FORGE.typeName + FORGE.lastField = nil + forgeRefresh() + elseif letter == "r" then + if FORGE.game.rules[first] ~= nil then + FORGE.rule = first + FORGE.part = math.min(second or 0, #forgeParts(FORGE.game.rules[first])) + end + FORGE.lastField = nil + forgeRefresh() + elseif letter == "k" then + if room.tracks[first] ~= nil then + local keys = forgeTrackKeys(room.tracks[first]) + + FORGE.track = first + FORGE.key = ((second or 0) <= #keys) and (second or 0) or 0 + if FORGE.key > 0 then + forgeCursorTo(keys[FORGE.key].at) + end + end + FORGE.lastField = nil + forgeRefresh() + elseif letter == "d" then + local item = forgeDialogueRows()[first] + + if item then + dialogueSelect(item) + end + forgeRefresh() + elseif letter == "p" then + local picker = FORGE.picker + local shown = picker and forgePickItems() or {} + + if shown[first] then + FORGE.picker = nil + picker.choose(shown[first].name) + end + elseif letter == "f" then + forgeEditField(first) + end +end + + +-- Starts typing one field of the form, by its place in it: the field before it is made the last +-- one walked, and the walk goes on from there. +function forgeEditField(index) + local fields = forgeFields() + + if (fields == nil) or (fields.names[index] == nil) then + return false + end + if FORGE.editing ~= nil then + forgeEditCommit() + end + FORGE.lastField = (index > 1) and fields.names[index - 1] or nil + + return forgeEditNext() +end + + -- Whether an x lies under the chrome as it is placed right now. function forgeOverPanel(x) - return (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W) + return not FORGE.panelHidden and (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W) +end + + +-- Hides the chrome, or shows it again: a polygon is drawn with the whole canvas bare. +function forgePanelShow(on) + FORGE.panelHidden = not on + if FORGE.gui ~= nil then + element("panel").style.display = on and "block" or "none" + end end @@ -1135,8 +1575,6 @@ end function forgePanelGrab(x) FORGE.panelDrag = x - FORGE.panelX - FORGE.message = "moving the panel" - forgeRefresh() end @@ -1203,7 +1641,7 @@ end -- ===== The 3D preview ========================================================================= -- -- A room in a game with a scene3d layer is shown as the scene itself: the room's entities are --- built from their kinds' looks, exactly as the runtime builds them, under an editor camera that +-- built from their types' looks, exactly as the runtime builds them, under an editor camera that -- orbits whatever is selected. Nothing steps -- no behaviours, no physics -- so it is the -- description that is on screen, not a running game. @@ -1224,10 +1662,10 @@ local function sceneStamp() local parts = {} for _, entry in ipairs(forgeRoom().entities) do - local kind = FORGE.game.kinds[entry.kind] or {} - local look = kind.look or {} + local entityType = FORGE.game.types[entry.type] or {} + local look = entityType.look or {} - parts[#parts + 1] = tostring(entry.kind) .. ":" .. tostring(look.kind) .. ":" .. tostring(look.file) .. ":" .. tostring(look.shape) .. ":" .. tostring(look.w) .. ":" .. tostring(look.h) .. ":" .. tostring(look.d) .. ":" .. tostring(look.scale) .. ":" .. tostring(look.r) .. tostring(look.g) .. tostring(look.b) + parts[#parts + 1] = tostring(entry.type) .. ":" .. tostring(look.kind) .. ":" .. tostring(look.file) .. ":" .. tostring(look.shape) .. ":" .. tostring(look.w) .. ":" .. tostring(look.h) .. ":" .. tostring(look.d) .. ":" .. tostring(look.scale) .. ":" .. tostring(look.r) .. tostring(look.g) .. tostring(look.b) end return table.concat(parts, "|") .. "#" .. FORGE.room @@ -1273,8 +1711,8 @@ function forgeSceneSync() if scene.stamp ~= sceneStamp() then forgeSceneClear() for index, entry in ipairs(room.entities) do - local kind = FORGE.game.kinds[entry.kind] or {} - local look = kind.look or { kind = "none" } + local entityType = FORGE.game.types[entry.type] or {} + local look = entityType.look or { kind = "none" } local instance = { node = nodeNew(), look = look, vars = { text = (entry.vars and entry.vars.text) or look.text }, nodes = {}, preview = true } local maker = AUTHOR.looks[look.kind] @@ -1473,9 +1911,97 @@ end -- Draws the description. Note that this draws the *description*, not a running game: there are -- no physics here, which is why an entity can be dragged through a wall. +-- The first element under one with a class, searched depth first, or nil. +local function findClass(under, class) + for index = 1, #under.child_nodes do + local child = under.child_nodes[index] + + if child:IsClassSet(class) then + return child + end + local found = findClass(child, class) + + if found then + return found + end + end + + return nil +end + + +-- Where an element's top is within a box it is inside, in the box's content coordinates: a +-- scrolling box is an offset parent, so offsets are summed up the chain until the box. +local function offsetWithin(what, box) + local top = what.offset_top + local parent = what.offset_parent + + while parent and (parent.id ~= box.id) do + top = top + parent.offset_top + parent = parent.offset_parent + end + + return top +end + + +-- Scrolls a row into its box's view, moving the box as little as possible. +local function scrollTo(box, row) + local top = offsetWithin(row, box) + local bottom = top + row.offset_height + + if top < box.scroll_top then + box.scroll_top = top + elseif bottom > box.scroll_top + box.client_height then + box.scroll_top = bottom - box.client_height + end +end + + +-- What can only be done once the panel is laid out, which the engine does at the end of the +-- frame the refresh happened in: the chosen row of the list and the field being edited are +-- scrolled into view, and a dropdown that would run off the bottom of the panel opens upward +-- instead. Run at the start of every draw, and done on the first draw after the frame the +-- refresh happened in: a key or a click is handled before that frame's draw, and the elements +-- it made measure nothing until the engine has laid them out. A refresh asks for it again. +function forgePlace() + FORGE.frame = (FORGE.frame or 0) + 1 + if not FORGE.placing or (FORGE.gui == nil) or (FORGE.frame <= FORGE.placing + 1) then + return + end + FORGE.placing = false + local list = element("list") + local detail = element("detail") + local chosen = findClass(list, "selected") or findClass(list, "here") + local field = findClass(detail, "here") + local drop = element("drop") + + if chosen then + scrollTo(list, chosen) + end + if field then + scrollTo(detail, field) + end + if drop and FORGE.dropRow and element(FORGE.dropRow) then + local row = element(FORGE.dropRow) + local panel = element("panel") + local top = offsetWithin(detail, panel) + offsetWithin(row, detail) - detail.scroll_top + local bottom = top + row.offset_height + drop.offset_height + + if (bottom > panel.offset_height) and (top - drop.offset_height >= 0) then + -- Up: a negative top margin lifts it from its place under the row to above it, + -- and it stays with the row when the box scrolls. + drop.style["margin-top"] = tostring(-(drop.offset_height + row.offset_height)) .. "px" + end + drop.style.visibility = "visible" + end +end + + function forgeDraw(pointerX, pointerY) local room = forgeRoom() + forgePlace() if FORGE.scene then forgeDraw3D(pointerX, pointerY) return @@ -1540,8 +2066,8 @@ function forgeDraw(pointerX, pointerY) end colorForeground(200, 160, 90, 255) for _, entry in ipairs(room.entities) do - local kind = FORGE.game.kinds[entry.kind] - local spot = kind and behaviourOf(kind, "hotspot") or nil + local entityType = FORGE.game.types[entry.type] + local spot = entityType and behaviourOf(entityType, "hotspot") or nil local polygon = spot and authorNumbers(spot.polygon) or nil for at = 1, (polygon and #polygon - 1 or 0), 2 do @@ -1585,12 +2111,13 @@ function forgeDraw(pointerX, pointerY) overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36) + -- The pointer goes on after the chrome: what is drawn after guiDraw is composited over it. + guiDraw(FORGE.gui) if pointerX ~= nil then colorForeground(255, 220, 60, 255) overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) end - guiDraw(FORGE.gui) end @@ -1599,6 +2126,8 @@ function forgeDraw3D(pointerX, pointerY) local room = forgeRoom() forgeSceneSync() + -- The overlay is clear glass over the scene, whatever a flat room left the ground set to. + colorBackground(0, 0, 0, 0) overlayClear() for index, entry in ipairs(room.entities) do local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0) @@ -1660,12 +2189,13 @@ function forgeDraw3D(pointerX, pointerY) overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16) overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36) + -- The pointer goes on after the chrome: what is drawn after guiDraw is composited over it. + guiDraw(FORGE.gui) if pointerX ~= nil then colorForeground(255, 220, 60, 255) overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) end - guiDraw(FORGE.gui) end @@ -1682,14 +2212,14 @@ function forgeDrawStrip() if left > right - 40 then left = STRIP_PAD end - local keys = track and (track.boxes or track.points) or nil + local keys = track and forgeTrackKeys(track) or nil if keys and #keys > 0 then first = math.huge last = -math.huge for _, key in ipairs(keys) do - first = math.min(first, key.at) - last = math.max(last, key.at) + first = math.min(first, tonumber(key.at) or 0) + last = math.max(last, tonumber(key.at) or 0) end if last == first then last = first + 1 @@ -1705,7 +2235,7 @@ function forgeDrawStrip() colorForeground(110, 110, 140, 255) overlayLine(left, top + STRIP_H / 2, right, top + STRIP_H / 2) for at, key in ipairs(keys or {}) do - local x = left + (key.at - first) / span * (right - left) + local x = left + ((tonumber(key.at) or 0) - first) / span * (right - left) if at == FORGE.key then colorForeground(255, 210, 70, 255) @@ -1759,8 +2289,8 @@ local function swapTo(copy, other, message) if FORGE.selected == 0 then FORGE.selected = nil end - if FORGE.game.kinds[FORGE.kindName or ""] == nil then - FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] + if FORGE.game.types[FORGE.typeName or ""] == nil then + FORGE.typeName = sortedKeys(FORGE.game.types)[1] end FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules)) FORGE.part = math.min(FORGE.part, #forgeParts(FORGE.game.rules[FORGE.rule] or {})) @@ -1803,19 +2333,19 @@ end -- ===== Kinds ================================================================================== -- --- A kind is a look, behaviours, and vars. All of it is edited the way a rule's parameters are: +-- A type is a look, behaviours, and vars. All of it is edited the way a rule's parameters are: -- ENTER walks the fields, and each is typed. The fields come from the manifest -- a look's -- parameters, a behaviour's parameters -- so a new look or behaviour is editable the moment it is -- declared. --- A name no other kind has, from a stem. -local function uniqueKind(stem) +-- A name no other type has, from a stem. +local function uniqueType(stem) local n = 1 - if FORGE.game.kinds[stem] == nil then + if FORGE.game.types[stem] == nil then return stem end - while FORGE.game.kinds[stem .. n] ~= nil do + while FORGE.game.types[stem .. n] ~= nil do n = n + 1 end @@ -1823,32 +2353,32 @@ local function uniqueKind(stem) end -function forgeKindAdd() - local name = uniqueKind("thing") +function forgeTypeAdd() + local name = uniqueType("thing") forgeRemember() - FORGE.game.kinds[name] = { look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, vars = {}, behaviours = {} } - FORGE.kindName = name + FORGE.game.types[name] = { look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, vars = {}, behaviours = {} } + FORGE.typeName = name FORGE.lastField = nil FORGE.dirty = true - FORGE.message = "added kind " .. name + FORGE.message = "added type " .. name forgeRefresh() return name end -function forgeKindDuplicate() - local kind = FORGE.game.kinds[FORGE.kindName or ""] +function forgeTypeDuplicate() + local entityType = FORGE.game.types[FORGE.typeName or ""] local name - if kind == nil then + if entityType == nil then return nil end - name = uniqueKind(FORGE.kindName) + name = uniqueType(FORGE.typeName) forgeRemember() - FORGE.game.kinds[name] = copyOf(kind) - FORGE.kindName = name + FORGE.game.types[name] = copyOf(entityType) + FORGE.typeName = name FORGE.lastField = nil FORGE.dirty = true FORGE.message = "copied as " .. name @@ -1858,16 +2388,16 @@ function forgeKindDuplicate() end --- A kind still placed somewhere cannot go: the entities would have nothing to be. -function forgeKindDelete() - local name = FORGE.kindName +-- A type still placed somewhere cannot go: the entities would have nothing to be. +function forgeTypeDelete() + local name = FORGE.typeName - if (name == nil) or (FORGE.game.kinds[name] == nil) then + if (name == nil) or (FORGE.game.types[name] == nil) then return false end for _, room in ipairs(FORGE.game.rooms) do for _, entry in ipairs(room.entities) do - if entry.kind == name then + if entry.type == name then FORGE.message = name .. " is placed in " .. room.name .. "; delete those first" forgeRefresh() return false @@ -1875,22 +2405,22 @@ function forgeKindDelete() end end forgeRemember() - FORGE.game.kinds[name] = nil - FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] + FORGE.game.types[name] = nil + FORGE.typeName = sortedKeys(FORGE.game.types)[1] FORGE.dirty = true - FORGE.message = "deleted kind " .. name + FORGE.message = "deleted type " .. name forgeRefresh() return true end --- Renaming a kind renames every entity of it and every rule that names it. -local function renameKind(from, to) +-- Renaming a type renames every entity of it and every rule that names it. +local function renameType(from, to) for _, room in ipairs(FORGE.game.rooms) do for _, entry in ipairs(room.entities) do - if entry.kind == from then - entry.kind = to + if entry.type == from then + entry.type = to end end end @@ -1899,7 +2429,7 @@ local function renameKind(from, to) rule.each = to end for key, kind in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do - if (kind == "kind") and (rule[key] == from) then + if (kind == "type") and (rule[key] == from) then rule[key] = to end end @@ -1908,19 +2438,19 @@ local function renameKind(from, to) local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] for key, kind in pairs(entry and entry.params or {}) do - if (kind == "kind") and (item[key] == from) then + if (kind == "type") and (item[key] == from) then item[key] = to end end end end end - for _, kind in pairs(FORGE.game.kinds) do - for _, b in ipairs(kind.behaviours or {}) do + for _, entityType in pairs(FORGE.game.types) do + for _, b in ipairs(entityType.behaviours or {}) do local entry = AUTHOR.behaviours[b.kind] for key, type_ in pairs(entry and entry.params or {}) do - if (type_ == "kind") and (b[key] == from) then + if (type_ == "type") and (b[key] == from) then b[key] = to end end @@ -1929,32 +2459,42 @@ local function renameKind(from, to) end --- Fills in whatever a look or a behaviour declares and the table does not yet have, so a kind +-- Fills in whatever a look or a behaviour declares and the table does not yet have, so a type -- that was just typed in has a value for every parameter and compiles at once. -local function fillDefaults(t, params) +-- Fills in what a new thing must have to mean anything -- the entity self, a key, a switch, the +-- lit type -- and leaves the rest empty, so a shake shakes and a clip plays at full volume until +-- the author says otherwise. +-- A behaviour or a layer (sparse) fills in less than a rule's part: a key or a switch it is +-- not given it defaults for itself when the game runs -- a gun fires on the mouse button, a +-- pressed event needs the one it is for. +fillDefaults = function(t, params, sparse) for key, kind in pairs(params or {}) do - if (t[key] == nil) and (FORGE_DEFAULTS[kind] ~= nil) then - t[key] = FORGE_DEFAULTS[kind] + if t[key] == nil then + if kind == "type" then + t[key] = FORGE.typeName or "" + elseif (FORGE_DEFAULTS[kind] ~= nil) and not (sparse and ((kind == "scancode") or (kind == "switch"))) then + t[key] = FORGE_DEFAULTS[kind] + end end end end --- A kind's fields, in the order the panel shows them: its name, its look and what that takes, its +-- A type's fields, in the order the panel shows them: its name, its look and what that takes, its -- vars, then its behaviours and what each of those takes. A behaviour's parameter is named -- behaviour.parameter, which is how it is typed as well. -function forgeKindFieldNames(kind) +function forgeTypeFieldNames(entityType) local names = { "name", "look" } - local look = AUTHOR.looks[(kind.look or {}).kind] + local look = AUTHOR.looks[(entityType.look or {}).kind] - for _, key in ipairs(sortedKeys(look and look.params or kind.look)) do + for _, key in ipairs(sortedKeys(look and look.params or entityType.look)) do if key ~= "kind" then names[#names + 1] = "look." .. key end end names[#names + 1] = "vars" names[#names + 1] = "behaviours" - for _, b in ipairs(kind.behaviours or {}) do + for _, b in ipairs(entityType.behaviours or {}) do local entry = AUTHOR.behaviours[b.kind] for _, key in ipairs(sortedKeys(entry and entry.params or b)) do @@ -2001,77 +2541,73 @@ local function varsFrom(text) end -function forgeKindGet(kind, name) +function forgeTypeGet(entityType, name) local group, key = string.match(name, "^([^.]+)%.(.+)$") if name == "name" then - return FORGE.kindName + return FORGE.typeName elseif name == "look" then - return (kind.look or {}).kind + return (entityType.look or {}).kind elseif name == "vars" then - return varsText(kind.vars) + return varsText(entityType.vars) elseif name == "behaviours" then - local kinds = {} - - for _, b in ipairs(kind.behaviours or {}) do - kinds[#kinds + 1] = b.kind - end - return table.concat(kinds, ", ") + return listText(entityType.behaviours) elseif group == "look" then - return (kind.look or {})[key] + return tableText((entityType.look or {})[key]) elseif group ~= nil then - local b = behaviourOf(kind, group) + local b = behaviourOf(entityType, group) - return b and b[key] or nil + return b and tableText(b[key]) or nil end return nil end -function forgeKindSet(kind, name, value) +-- What a typed value becomes for a parameter of a manifest type: tables are parsed, the rest +-- is as typed. +local function valueFor(params, key, value) + if ((params or {})[key] == "table") and (type(value) == "string") then + return tableFrom(value) + end + + return value +end + + +function forgeTypeSet(entityType, name, value) local group, key = string.match(name, "^([^.]+)%.(.+)$") - kind.look = kind.look or {} + entityType.look = entityType.look or {} if name == "name" then - if (value ~= "") and (value ~= FORGE.kindName) and (FORGE.game.kinds[value] == nil) then - renameKind(FORGE.kindName, value) - FORGE.game.kinds[value] = kind - FORGE.game.kinds[FORGE.kindName] = nil - FORGE.kindName = value + if (value ~= "") and (value ~= FORGE.typeName) and (FORGE.game.types[value] == nil) then + renameType(FORGE.typeName, value) + FORGE.game.types[value] = entityType + FORGE.game.types[FORGE.typeName] = nil + FORGE.typeName = value end elseif name == "look" then - if AUTHOR.looks[value] ~= nil then - kind.look.kind = value - fillDefaults(kind.look, AUTHOR.looks[value].params) + if value == entityType.look.kind then + return + elseif AUTHOR.looks[value] ~= nil then + -- A new look starts clean: a box's size and colour mean nothing to a sprite. + entityType.look = { kind = value } + fillDefaults(entityType.look, AUTHOR.looks[value].params, true) else FORGE.message = "no look called " .. tostring(value) end elseif name == "vars" then - kind.vars = varsFrom(value) + entityType.vars = varsFrom(value) elseif name == "behaviours" then - -- A comma separated list of behaviours. One already carried keeps its values; a new one - -- starts with defaults; one left out goes. - local list = {} - - for want in string.gmatch(tostring(value), "[^,%s]+") do - local b = behaviourOf(kind, want) or { kind = want } - - if AUTHOR.behaviours[want] ~= nil then - fillDefaults(b, AUTHOR.behaviours[want].params) - list[#list + 1] = b - else - FORGE.message = "no behaviour called " .. want - end - end - kind.behaviours = list + FORGE.stash[entityType] = FORGE.stash[entityType] or {} + entityType.behaviours = listFrom(value, entityType.behaviours, AUTHOR.behaviours, FORGE.stash[entityType], "behaviour") elseif group == "look" then - kind.look[key] = value + entityType.look[key] = valueFor((AUTHOR.looks[entityType.look.kind] or {}).params, key, value) elseif group ~= nil then - local b = behaviourOf(kind, group) + local b = behaviourOf(entityType, group) if b ~= nil then - b[key] = value + b[key] = valueFor((AUTHOR.behaviours[group] or {}).params, key, value) end end FORGE.dirty = true @@ -2100,22 +2636,36 @@ local function uniqueId(stem) end --- A new entity of the selected kind in the middle of the canvas, clear of the panel, selected and --- ready to be moved. With no kinds yet, one is made first. +-- A new entity of the selected type in the middle of the canvas, clear of the panel, selected and +-- ready to be moved. With no types yet, one is made first. function forgeEntityAdd(x, y) local room = forgeRoom() - local kind = FORGE.kindName + local typeName = FORGE.typeName - if (kind == nil) or (FORGE.game.kinds[kind] == nil) then - kind = forgeKindAdd() + if (typeName == nil) or (FORGE.game.types[typeName] == nil) then + typeName = forgeTypeAdd() end forgeRemember() - room.entities[#room.entities + 1] = { - kind = kind, - id = uniqueId(kind), - x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), - y = math.floor(y or (overlayGetHeight() / 2)) - } + if FORGE.scene then + -- In a scene the new entity stands on the floor under the middle of the canvas, or at the + -- origin when nothing is there to stand on. + local gx, gz = forgeGroundPoint((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2, overlayGetHeight() / 2, 0) + + room.entities[#room.entities + 1] = { + type = typeName, + id = uniqueId(typeName), + x = gx and (math.floor(gx * 10 + 0.5) / 10) or 0, + y = 0, + z = gz and (math.floor(gz * 10 + 0.5) / 10) or 0 + } + else + room.entities[#room.entities + 1] = { + type = typeName, + id = uniqueId(typeName), + x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), + y = math.floor(y or (overlayGetHeight() / 2)) + } + end FORGE.dirty = true FORGE.message = "added " .. room.entities[#room.entities].id forgeSelect(#room.entities) @@ -2135,7 +2685,7 @@ function forgeEntityDuplicate() end forgeRemember() twin = copyOf(entry) - twin.id = uniqueId(entry.kind) + twin.id = uniqueId(entry.type) twin.x = entry.x + 20 twin.y = entry.y + 20 room.entities[#room.entities + 1] = twin @@ -2168,9 +2718,9 @@ function forgeEntityDelete() end --- An entity's fields: what every entity has, then the vars its kind declares, overridable here. +-- An entity's fields: what every entity has, then the vars its type declares, overridable here. function forgeEntityFieldNames(entry) - local names = { "id", "kind", "x", "y", "z" } + local names = { "id", "type", "x", "y", "z" } if FORGE.scene then names[#names + 1] = "rx" @@ -2178,9 +2728,9 @@ function forgeEntityFieldNames(entry) end names[#names + 1] = "rz" names[#names + 1] = "scale" - local kind = FORGE.game.kinds[entry.kind] or {} + local entityType = FORGE.game.types[entry.type] or {} - for _, key in ipairs(sortedKeys(kind.vars)) do + for _, key in ipairs(sortedKeys(entityType.vars)) do names[#names + 1] = "vars." .. key end @@ -2192,13 +2742,13 @@ function forgeEntityGet(entry, name) local key = string.match(name, "^vars%.(.+)$") if key ~= nil then - local kind = FORGE.game.kinds[entry.kind] or {} + local entityType = FORGE.game.types[entry.type] or {} if entry.vars and entry.vars[key] ~= nil then return entry.vars[key] end - return (kind.vars or {})[key] + return (entityType.vars or {})[key] end if name == "scale" then return entry.scale or 1 @@ -2234,11 +2784,11 @@ function forgeEntitySet(entry, name, value) renameEverywhere(entry.id, value) entry.id = value end - elseif name == "kind" then - if FORGE.game.kinds[value] ~= nil then - entry.kind = value + elseif name == "type" then + if FORGE.game.types[value] ~= nil then + entry.type = value else - FORGE.message = "no kind called " .. tostring(value) + FORGE.message = "no type called " .. tostring(value) end elseif (name == "x") or (name == "y") or (name == "z") then if type(value) == "number" then @@ -2290,14 +2840,16 @@ end -- ===== Drawing polygons ======================================================================= -- -- A walk area, or a hotspot's outline, is drawn on the canvas corner by corner: V starts one, --- every press adds a corner, V or ENTER closes it. It goes to the selected entity's kind when --- that kind has a hotspot behaviour, and to the room's walk areas otherwise. +-- every press adds a corner, V or ENTER closes it. It goes to the selected entity's type when +-- that type has a hotspot behaviour, and to the room's walk areas otherwise. function forgePolygonBegin() local entry = forgeRoom().entities[FORGE.selected] - local kind = entry and FORGE.game.kinds[entry.kind] or nil - local spot = kind and behaviourOf(kind, "hotspot") or nil + local entityType = entry and FORGE.game.types[entry.type] or nil + local spot = entityType and behaviourOf(entityType, "hotspot") or nil + -- The panel steps aside while corners are pressed, since a press under it is the panel's. + forgePanelShow(false) FORGE.drawing = { points = {}, hotspot = spot } FORGE.message = spot and ("drawing " .. entry.id .. "'s outline: press each corner") or "drawing a walk area: press each corner" forgeRefresh() @@ -2309,6 +2861,7 @@ function forgePolygonClose() local room = forgeRoom() FORGE.drawing = nil + forgePanelShow(true) if #drawing.points < 6 then FORGE.message = "a polygon needs three corners" forgeRefresh() @@ -2317,9 +2870,9 @@ function forgePolygonClose() forgeRemember() if drawing.hotspot then local entry = forgeRoom().entities[FORGE.selected] - local kind = FORGE.game.kinds[entry.kind] + local entityType = FORGE.game.types[entry.type] - behaviourOf(kind, "hotspot").polygon = drawing.points + behaviourOf(entityType, "hotspot").polygon = drawing.points FORGE.message = "outline drawn" else room.walk = room.walk or {} @@ -2367,37 +2920,31 @@ function forgeMakePart(set, name) if entry == nil then return nil end - for key, kind in pairs(entry.params or {}) do - if (kind == "kind") then - item[key] = FORGE.kindName or "" - elseif FORGE_DEFAULTS[kind] ~= nil then - item[key] = FORGE_DEFAULTS[kind] - end - end + fillDefaults(item, entry.params) return item end -- Adds a condition ("when"), a condition in the rule's any group ("any"), or an action ("act"). -function forgeRuleAdd(kindName, name) +function forgeRuleAdd(partKind, name) local rule = FORGE.game.rules[FORGE.rule] - local set = (kindName == "act") and AUTHOR.actions or AUTHOR.conditions + local set = (partKind == "act") and AUTHOR.actions or AUTHOR.conditions local item = forgeMakePart(set, name) local list if (rule == nil) or (item == nil) then - FORGE.message = "no such " .. kindName + FORGE.message = "no such " .. partKind return false end forgeRemember() rule.when = rule.when or {} rule.act = rule.act or {} - if kindName == "any" then + if partKind == "any" then rule.when.any = rule.when.any or {} list = rule.when.any else - list = rule[kindName] + list = rule[partKind] end list[#list + 1] = item FORGE.dirty = true @@ -2530,8 +3077,8 @@ function forgeRuleOn(name) end rule.on = name for key, kind in pairs(AUTHOR.events[name].filter) do - if kind == "kind" then - rule[key] = FORGE.kindName + if kind == "type" then + rule[key] = FORGE.typeName end end FORGE.part = 0 @@ -2550,7 +3097,9 @@ function forgePartText(item) local out = {} for _, key in ipairs(forgeFieldNames(item)) do - out[#out + 1] = key .. " " .. tostring(item[key]) + if item[key] ~= nil then + out[#out + 1] = key .. ": " .. tostring(item[key]) + end end return item[1] .. (( #out > 0) and (": " .. table.concat(out, ", ")) or "") @@ -2588,11 +3137,15 @@ end -- A track is keys by disc frame or by time. The cursor is where the timeline stands; the canvas -- shows the frame of the disc layer's video under it and every track's box there. -function forgeTrackNew(name) +-- A new track: boxes over the picture, or a rail of points in a scene -- or, with waves, a +-- track of spawns, keyed by time or frame like the others, or by a rail's stops. +function forgeTrackNew(name, waves) local room = forgeRoom() forgeRemember() - if FORGE.scene then + if waves then + room.tracks[#room.tracks + 1] = { name = name or "waves", key = FORGE.scene and "time" or "frame", spawns = {} } + elseif FORGE.scene then room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "time", points = {} } else room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "frame", boxes = {} } @@ -2600,7 +3153,7 @@ function forgeTrackNew(name) FORGE.track = #room.tracks FORGE.key = 0 FORGE.dirty = true - FORGE.message = "track added: K adds a key at the cursor" + FORGE.message = waves and "waves track added: K adds a wave at the cursor; key stop ties waves to a rail's stops" or "track added: K adds a key at the cursor" forgeRefresh() return FORGE.track @@ -2625,9 +3178,19 @@ function forgeTrackDelete() end --- A track's keys, whichever kind it holds: boxes over a picture, or points along a rail. +-- A track's keys, whichever kind it holds: boxes over a picture, points along a rail, or waves. function forgeTrackKeys(track) - return track.boxes or track.points or {} + return track.boxes or track.points or track.spawns or {} +end + + +-- Keys in order: by their moment, or by their stop's name on a track keyed by stops. +function forgeKeyOrder(a, b) + if type(a.at) == type(b.at) then + return a.at < b.at + end + + return tostring(a.at) < tostring(b.at) end @@ -2694,7 +3257,25 @@ function forgeKeyAdd() return false end forgeRemember() - if track.points then + if track.spawns then + -- A wave at the cursor -- or at a stop still to be named -- of one of the first type, at + -- the middle of the canvas, to be typed into shape. + local key = { at = (track.key == "stop") and "stop" or FORGE.cursor, type = sortedKeys(FORGE.game.types)[1], count = 1 } + + if FORGE.scene then + key.x, key.y, key.z = 0, 0, 0 + else + key.x, key.y = math.floor((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2), math.floor(overlayGetHeight() / 2) + end + track.spawns[#track.spawns + 1] = key + table.sort(track.spawns, forgeKeyOrder) + for at, item in ipairs(track.spawns) do + if item == key then + FORGE.key = at + end + end + FORGE.dirty = true + elseif track.points then -- A rail point is put where the editor camera is, looking where it looks: stand where the -- player should and press K. local cx, cy, cz = nodeGetPosition(FORGE.scene.camera) @@ -2777,14 +3358,88 @@ function forgePickBegin(title, set, choose) end --- The picker's items that match what has been typed. +-- Opens the list a field's values come from, if it has one. Answers whether it did. +function forgeFieldList(fields, field) + local items, kind, free = forgeFieldChoices(field) + local was = fields.get(field) + + if items == nil then + return false + end + -- ENTER on a row takes it and moves on to the next field, as ENTER on a typed value does, so + -- the form is still walked one ENTER per field; a click takes it and stays. The value the + -- field already has is left alone: choosing a type's look again would empty its parameters. + forgePickBegin(((kind == "file") and "a file for " or "a value for ") .. field, items, function(word, onward) + local value = word + + if word == FILE_NONE then + value = nil + elseif (kind == "boolean") or (kind == "flag") then + value = (word == "true") + end + if word ~= tostring(was) then + fields.set(field, value) + end + FORGE.message = field .. " = " .. word + if onward and (FORGE.editing ~= nil) and (FORGE.editing.key == field) then + forgeEditNext() + else + FORGE.editing = nil + end + end) + if FORGE.picker then + local found = nil + + FORGE.picker.free = free + FORGE.picker.field = true -- Drawn under the field's row, not over the panel's list. + for at, item in ipairs(FORGE.picker.items) do + if item.name == tostring(was) then + found = at + end + end + -- A value the list does not know -- a room not made yet, a word of its own -- is offered + -- as it is, so walking past the field with ENTER keeps it. + if (found == nil) and (was ~= nil) then + found = math.min(2, #FORGE.picker.items + 1) + table.insert(FORGE.picker.items, found, { name = tostring(was), help = "as it is" }) + end + FORGE.picker.at = found or 1 + forgeRefresh() -- Drawn again as a dropdown: forgePickBegin drew it over the list. + end + + return true +end + + +-- The picker's items that match what has been typed. A free list -- a state -- offers what was +-- typed first, when it is not a name already, so ENTER takes a new word as readily as a known. function forgePickItems() local picker = FORGE.picker + local filter = picker.filter:lower() local shown = {} - for _, item in ipairs(picker.items) do - if (picker.filter == "") or item.name:lower():find(picker.filter:lower(), 1, true) then - shown[#shown + 1] = item + if picker.free and (picker.filter ~= "") then + local known = false + + for _, item in ipairs(picker.items) do + known = known or (item.name == picker.filter) + end + if not known then + shown[1] = { name = picker.filter, help = "as typed" } + end + end + + -- What was typed exactly comes first, then what begins with it, then what merely contains + -- it, so "miss" lands on miss and not on branchMissed. + for _, rank in ipairs({ 1, 2, 3 }) do + for _, item in ipairs(picker.items) do + local name = item.name:lower() + local at = (filter ~= "") and name:find(filter, 1, true) or nil + local mine = (filter == "") and 3 or ((name == filter) and 1 or ((at == 1) and 2 or (at and 3 or nil))) + + if mine == rank then + shown[#shown + 1] = item + end end end @@ -2808,7 +3463,7 @@ function forgePickKey(scancode, keysym) elseif scancode == SCANCODE.RETURN.value then if shown[picker.at] then FORGE.picker = nil - picker.choose(shown[picker.at].name) + picker.choose(shown[picker.at].name, true) end elseif scancode == SCANCODE.ESCAPE.value then FORGE.picker = nil @@ -2816,7 +3471,7 @@ function forgePickKey(scancode, keysym) elseif scancode == SCANCODE.BACKSPACE.value then picker.filter = string.sub(picker.filter, 1, -2) picker.at = 1 - elseif keysym and (keysym >= 32) and (keysym < 127) then + elseif keysym and (keysym >= 32) and (keysym < 127) and not FORGE.textInput then picker.filter = picker.filter .. string.char(keysym) picker.at = 1 else @@ -2831,6 +3486,7 @@ end -- The files under the game's directory a field could name, as picker items. function forgeFileItems() local found = {} + local seen = {} local function walk(directory, prefix, depth) -- lfs.dir hands back its iterator and the directory it walks; both go to the for. @@ -2845,6 +3501,7 @@ function forgeFileItems() local mode = lfs.attributes(directory .. "/" .. name, "mode") if (mode == "directory") and (depth < FILE_DEPTH) then + seen[path .. "/"] = true walk(directory .. "/" .. name, path .. "/", depth + 1) elseif mode == "file" then local extension = name:match("%.([%w]+)$") @@ -2857,6 +3514,10 @@ function forgeFileItems() end end walk(".", "", 0) + -- The kit travels inside Forge.game, which the walk from the games directory cannot see. + if not seen["Forge/kit/"] then + walk("Forge/kit", "Forge/kit/", 0) + end table.sort(found, function(a, b) return a.name < b.name end) return found @@ -2864,35 +3525,213 @@ end -- Whether a field names a file, by the manifest's word for it. -function forgeFieldIsFile(name) +-- What a field is, for the list it opens: the manifest's type for a parameter ("file", +-- "boolean", "scancode", "switch", "room", "type", "entity", "state", "track"), a list of the +-- words a parameter takes (the manifest's choices for it), or nil for a value typed free. The +-- editor's own fields -- a type's look, a rule's event, an entity's type, a track's key -- are +-- typed here as well. +function forgeFieldKind(name) local group, key = string.match(name, "^([^.]+)%.(.+)$") + local entry = nil + local param = name - if (name == "file") or (name == "document") or (name == "texture") or (name == "sky") then - return true - end - if FORGE.mode == "kinds" then - local kind = FORGE.game.kinds[FORGE.kindName or ""] + if FORGE.mode == "types" then + local entityType = FORGE.game.types[FORGE.typeName or ""] - if (group == "look") and kind and kind.look then - local look = AUTHOR.looks[kind.look.kind] - - return (look and look.params[key]) == "file" + if name == "look" then + return AUTHOR.looks + elseif (group == "look") and entityType and entityType.look then + entry = AUTHOR.looks[entityType.look.kind] + param = key elseif group then - local behaviour = AUTHOR.behaviours[group] - - return (behaviour and behaviour.params[key]) == "file" + entry = AUTHOR.behaviours[group] + param = key end elseif FORGE.mode == "rules" then + if FORGE.part == 0 then + local rule = FORGE.game.rules[FORGE.rule] or {} + local event = AUTHOR.events[rule.on or "frame"] + + if name == "on" then + return AUTHOR.events + elseif name == "each" then + return "type" + elseif name == "room" then + return "room" + elseif (name == "controls") or (name == "interrupt") then + return "flag" + end + + return event and event.filter[name] or nil + end local part = forgeParts(FORGE.game.rules[FORGE.rule] or {})[FORGE.part] if part then - local entry = AUTHOR.conditions[part.item[1]] or AUTHOR.actions[part.item[1]] + entry = AUTHOR.conditions[part.item[1]] or AUTHOR.actions[part.item[1]] + end + elseif FORGE.mode == "entities" then + if FORGE.selected ~= nil then + return (name == "type") and "type" or nil + elseif group then + -- The game's form: a layer's parameter, named layer.parameter. + entry = AUTHOR.layers[group] + param = key + elseif (name == "reset") or (name == "depthSort") then + return "flag" + end + elseif FORGE.mode == "tracks" then + local track = forgeRoom().tracks[FORGE.track] - return (entry and entry.params[name]) == "file" + if (FORGE.key == 0) and (name == "key") then + return (track and track.spawns) and { "frame", "time", "stop" } or { "frame", "time" } + elseif (FORGE.key > 0) and (name == "type") then + return "type" + end + elseif FORGE.mode == "dialogues" then + if (name == "start") or (name == "next") then + return "node" + elseif name == "once" then + return "flag" end end + if entry == nil then + return nil + end + if entry.choices and entry.choices[param] then + return entry.choices[param] + end - return false + return (entry.params or {})[param] +end + + +-- The list a field opens, as picker items, or nil for a value typed free: the files under the +-- game's directory, true and false, the scancodes and switches the engine names, the game's +-- rooms, types, and entities, a dialogue's nodes, a room's tracks, or the words the manifest +-- says. free says the list is a suggestion and the typed text is taken as well (a state a +-- rule sets can be any word, and a rule can name a type, a room, or an entity that is made +-- later). Every list but those that must name something offers (none): +-- the parameter left empty takes its default, or means the game itself for an entity. +function forgeFieldChoices(name) + local kind = forgeFieldKind(name) + local items = {} + local free = false + local none = true + + if kind == nil then + return nil + end + local function add(word, help) + items[#items + 1] = { name = word, help = help } + end + if type(kind) == "table" then + if kind[1] == nil then + -- A manifest: the looks, or the events. + for _, word in ipairs(sortedKeys(kind)) do + add(word, kind[word].help) + end + none = false + else + for _, word in ipairs(kind) do + add(word) + end + end + elseif kind == "file" then + items = forgeFileItems() + elseif (kind == "boolean") or (kind == "flag") then + add("true") + add("false") + none = (kind == "boolean") + elseif kind == "scancode" then + for _, word in ipairs(sortedKeys(SCANCODE)) do + add(word) + end + elseif kind == "switch" then + for _, word in ipairs(sortedKeys(_G)) do + if word:match("^SWITCH_") and (type(_G[word]) == "number") then + add(word) + end + end + elseif kind == "room" then + for _, room in ipairs(FORGE.game.rooms) do + add(room.name) + end + free = true + elseif kind == "type" then + for _, word in ipairs(sortedKeys(FORGE.game.types)) do + add(word, (FORGE.game.types[word].look or {}).kind) + end + none = (FORGE.mode ~= "entities") + free = none + elseif kind == "entity" then + local seen = {} + + add("self", "the instance the rule is on") + add("other", "the other one, in a collision or a trigger") + for _, room in ipairs(FORGE.game.rooms) do + for _, entry in ipairs(room.entities) do + if entry.id and not seen[entry.id] then + seen[entry.id] = true + add(entry.id, entry.type) + end + end + end + free = true + elseif kind == "state" then + local seen = {} + + -- The states the game already names: a frames behaviour's ranges, an animator's clips, + -- and the states rules set or test. + for _, entityType in pairs(FORGE.game.types) do + for _, b in ipairs(entityType.behaviours or {}) do + if b.kind == "frames" then + for word in tostring(b.states or ""):gmatch("(%w+)%s*=") do + seen[word] = true + end + elseif b.kind == "animator" then + for _, word in ipairs({ "idle", "walk", "attack", "hit", "dead" }) do + if b[word] then + seen[word] = true + end + end + end + end + end + for _, rule in ipairs(FORGE.game.rules) do + for _, list in ipairs({ rule.when or {}, rule.act or {} }) do + for _, item in ipairs(list) do + if type(item.state) == "string" then + seen[item.state] = true + end + end + end + end + seen.idle = true + for _, word in ipairs(sortedKeys(seen)) do + add(word) + end + free = true + elseif kind == "track" then + for _, track in ipairs(forgeRoom().tracks or {}) do + add(track.name) + end + free = true + elseif kind == "node" then + local dialogue = (FORGE.game.dialogues or {})[FORGE.dialogue or ""] + + for _, word in ipairs(sortedKeys(dialogue and dialogue.nodes or {})) do + add(word) + end + none = (name ~= "start") + free = true + else + return nil + end + if none then + table.insert(items, 1, { name = FILE_NONE, help = (kind == "file") and "no file" or "left empty" }) + end + + return items, kind, free end @@ -2901,13 +3740,13 @@ end -- Point the engine's callback at this and the editor is usable without a pointer at all, which is -- how the bundled menu has always worked and what a cabinet needs. -- --- TAB entities, kinds, rules, tracks +-- TAB entities, types, rules, tracks, dialogues -- UP / DOWN move within the list -- LEFT/RIGHT move the panel out of the way -- PGUP / PGDN the previous or next room -- ENTER type a value for what is selected; again for its next value -- ESC put the value back; otherwise close the description --- A / D add, duplicate (an entity, or a kind) DELETE delete what is selected +-- A / D add, duplicate (an entity, or a type) DELETE delete what is selected -- N new rule, new track, or (in the entities) new room -- E pick what the rule is on C / O / T add a condition / an "any" condition / an action -- [ / ] move the rule; in the tracks, leap the cursor @@ -2933,6 +3772,7 @@ function forgeKey(keysym, scancode) forgePolygonClose() elseif scancode == SCANCODE.ESCAPE.value then FORGE.drawing = nil + forgePanelShow(true) FORGE.message = "polygon dropped" forgeRefresh() end @@ -2947,6 +3787,24 @@ function forgeKey(keysym, scancode) forgeEditCancel() return end + -- The caret's keys. DELETE on a value just entered, still selected whole, empties it: + -- an action's entity cleared is the game itself. + if scancode == SCANCODE.DELETE.value then + forgeEditDelete() + return + elseif scancode == SCANCODE.LEFT.value then + forgeEditCaret(FORGE.editing.selected and #FORGE.editing.text or (FORGE.editing.cursor - 1)) + return + elseif scancode == SCANCODE.RIGHT.value then + forgeEditCaret(FORGE.editing.selected and #FORGE.editing.text or (FORGE.editing.cursor + 1)) + return + elseif scancode == SCANCODE.HOME.value then + forgeEditCaret(0) + return + elseif scancode == SCANCODE.END.value then + forgeEditCaret(#FORGE.editing.text) + return + end if forgeTyped(keysym) then return end @@ -2967,6 +3825,10 @@ function forgeKey(keysym, scancode) end forgeMode("entities") + -- The digits go straight to a panel, so types to entities is one key and not four. + elseif (scancode >= SCANCODE.MAIN_1.value) and (scancode <= SCANCODE.MAIN_5.value) then + forgeMode(MODES[scancode - SCANCODE.MAIN_1.value + 1]) + elseif scancode == SCANCODE.LEFT.value then forgePanelTo(FORGE.panelX - 40) elseif scancode == SCANCODE.RIGHT.value then @@ -3007,29 +3869,29 @@ function forgeKey(keysym, scancode) forgePolygonBegin() end - elseif FORGE.mode == "kinds" then - local names = sortedKeys(FORGE.game.kinds) + elseif FORGE.mode == "types" then + local names = sortedKeys(FORGE.game.types) local at = 0 for index, name in ipairs(names) do - if name == FORGE.kindName then + if name == FORGE.typeName then at = index end end if scancode == SCANCODE.DOWN.value then - FORGE.kindName = names[math.min(at + 1, #names)] or FORGE.kindName + FORGE.typeName = names[math.min(at + 1, #names)] or FORGE.typeName FORGE.lastField = nil forgeRefresh() elseif scancode == SCANCODE.UP.value then - FORGE.kindName = names[math.max(at - 1, 1)] or FORGE.kindName + FORGE.typeName = names[math.max(at - 1, 1)] or FORGE.typeName FORGE.lastField = nil forgeRefresh() elseif scancode == SCANCODE.A.value then - forgeKindAdd() + forgeTypeAdd() elseif scancode == SCANCODE.D.value then - forgeKindDuplicate() + forgeTypeDuplicate() elseif scancode == SCANCODE.DELETE.value then - forgeKindDelete() + forgeTypeDelete() end elseif FORGE.mode == "rules" then @@ -3126,6 +3988,8 @@ function forgeKey(keysym, scancode) forgeRefresh() elseif scancode == SCANCODE.N.value then forgeTrackNew() + elseif scancode == SCANCODE.W.value then + forgeTrackNew(nil, true) elseif scancode == SCANCODE.K.value then forgeKeyAdd() elseif scancode == SCANCODE.COMMA.value then @@ -3149,6 +4013,8 @@ function forgeKey(keysym, scancode) forgeSave() elseif scancode == SCANCODE.B.value then forgeBuild(singeGetDataPath() .. "preview.singe") + elseif scancode == SCANCODE.X.value then + forgeExport(forgeReleaseFolder()) elseif scancode == SCANCODE.U.value then forgeUndo() elseif scancode == SCANCODE.R.value then @@ -3164,16 +4030,24 @@ end -- -- Keysyms arrive as characters the way Tools.singe takes them, so this needs nothing of the GUI: -- ENTER starts editing whatever is selected, each press moves to its next field, and ESCAPE puts --- back what was there. An entity, a kind, a rule, a key, and a condition are typed the same way; +-- back what was there. An entity, a type, a rule, a key, and a condition are typed the same way; -- forgeFields says what the fields are and how to reach them. -- The parameters of a condition or action, in the order the panel shows them, so typing walks them -- the same way. +-- A part's fields: every parameter its condition or action takes, whether set yet or not, and +-- anything else the description gave it. function forgeFieldNames(item) - local keys = {} + local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] or {} + local seen = {} + local keys = {} + for key in pairs(entry.params or {}) do + seen[key] = true + keys[#keys + 1] = key + end for key in pairs(item) do - if key ~= 1 then + if (key ~= 1) and not seen[key] then keys[#keys + 1] = key end end @@ -3235,16 +4109,16 @@ function forgeFields() get = function(key) return part.item[key] end, set = forgePartSet } - elseif FORGE.mode == "kinds" then - local kind = FORGE.game.kinds[FORGE.kindName or ""] + elseif FORGE.mode == "types" then + local entityType = FORGE.game.types[FORGE.typeName or ""] - if kind == nil then + if entityType == nil then return nil end return { - names = forgeKindFieldNames(kind), - get = function(key) return forgeKindGet(kind, key) end, - set = function(key, value) forgeKindSet(kind, key, value) end + names = forgeTypeFieldNames(entityType), + get = function(key) return forgeTypeGet(entityType, key) end, + set = function(key, value) forgeTypeSet(entityType, key, value) end } elseif FORGE.mode == "dialogues" then return forgeDialogueFields() @@ -3259,7 +4133,7 @@ function forgeFields() names = { "name", "key" }, get = function(key) return track[key] end, set = function(key, value) - if (key ~= "key") or (value == "frame") or (value == "time") then + if (key ~= "key") or (value == "frame") or (value == "time") or ((value == "stop") and track.spawns) then track[key] = value end FORGE.dirty = true @@ -3271,7 +4145,7 @@ function forgeFields() local box = keys[FORGE.key] return { - names = track.points and { "at", "x", "y", "z", "lookX", "lookY", "lookZ", "stop" } or { "at", "x", "y", "w", "h" }, + names = track.spawns and { "at", "type", "count", "every", "from", "x", "y", "z" } or (track.points and { "at", "x", "y", "z", "lookX", "lookY", "lookZ", "stop" } or { "at", "x", "y", "w", "h" }), get = function(key) if key:sub(1, 4) == "look" then local look = box.look or {} @@ -3295,10 +4169,21 @@ function forgeFields() end elseif key == "stop" then box.stop = (value ~= "") and tostring(value) or nil + elseif track.spawns and ((key == "type") or (key == "from") or ((key == "at") and (track.key == "stop"))) then + -- A wave's words: its type, where it comes from, and the stop it waits for. + box[key] = ((value ~= nil) and (value ~= "")) and tostring(value) or nil + if key == "at" then + table.sort(keys, forgeKeyOrder) + for at, item in ipairs(keys) do + if item == box then + FORGE.key = at + end + end + end elseif type(value) == "number" then box[key] = value if key == "at" then - table.sort(keys, function(a, b) return a.at < b.at end) + table.sort(keys, forgeKeyOrder) for at, item in ipairs(keys) do if item == box then FORGE.key = at @@ -3314,9 +4199,9 @@ function forgeFields() local entry = room.entities[FORGE.selected] if entry == nil then - -- Nothing selected: the room itself. + -- Nothing selected: the game, then the room itself. return { - names = { "name", "reset", "depthSort", "walk", "navFrom", "scaleBy" }, + names = forgeRoomFieldNames(), get = function(key) return forgeRoomGet(room, key) end, set = function(key, value) forgeRoomSet(room, key, value) end } @@ -3332,8 +4217,57 @@ end -- A room's own fields, typed as text: walk areas as polygons of x,y pairs separated by -- semicolons, navFrom as a list of entity ids, scaleBy as y:scale pairs. +-- The fields with nothing selected: the game's -- its title, players, verbs, and layers with +-- what each layer takes, named layer.parameter as a behaviour's are -- and then the room's. +function forgeRoomFieldNames() + local names = { "title", "players", "vars", "verbs", "layers" } + + for _, layer in ipairs(FORGE.game.layers or {}) do + local entry = AUTHOR.layers[layer.kind] + + for _, key in ipairs(sortedKeys(entry and entry.params or layer)) do + if key ~= "kind" then + names[#names + 1] = layer.kind .. "." .. key + end + end + end + for _, key in ipairs({ "name", "reset", "depthSort", "walk", "navFrom", "scaleBy" }) do + names[#names + 1] = key + end + + return names +end + + +local function layerOf(name) + for _, layer in ipairs(FORGE.game.layers or {}) do + if layer.kind == name then + return layer + end + end + + return nil +end + + function forgeRoomGet(room, key) - if key == "walk" then + local group, field = string.match(key, "^([^.]+)%.(.+)$") + + if key == "title" then + return FORGE.game.title + elseif key == "players" then + return FORGE.game.players or 1 + elseif key == "vars" then + return varsText(FORGE.game.vars) + elseif key == "verbs" then + return table.concat(FORGE.game.verbs or {}, ", ") + elseif key == "layers" then + return listText(FORGE.game.layers) + elseif group ~= nil then + local layer = layerOf(group) + + return layer and tableText(layer[field]) or nil + elseif key == "walk" then local polygons = {} for _, polygon in ipairs(room.walk or {}) do @@ -3363,7 +4297,41 @@ end function forgeRoomSet(room, key, value) - if key == "walk" then + local group, field = string.match(key, "^([^.]+)%.(.+)$") + + if key == "title" then + FORGE.game.title = tostring(value) + elseif key == "players" then + if type(value) == "number" then + FORGE.game.players = (value ~= 1) and math.floor(value) or nil + end + elseif key == "vars" then + FORGE.game.vars = varsFrom(value) + elseif key == "verbs" then + local verbs = {} + + for verb in string.gmatch(tostring(value), "[^,%s]+") do + verbs[#verbs + 1] = verb + end + FORGE.game.verbs = (#verbs > 0) and verbs or nil + elseif key == "layers" then + FORGE.stash.layers = FORGE.stash.layers or {} + FORGE.game.layers = listFrom(value, FORGE.game.layers, AUTHOR.layers, FORGE.stash.layers, "layer") + -- A scene3d layer typed in or out turns the canvas into the viewport, or back. + if forgeIs3D() then + if FORGE.scene == nil then + forgeSceneBegin() + end + elseif FORGE.scene ~= nil then + forgeSceneClear() + end + elseif group ~= nil then + local layer = layerOf(group) + + if layer ~= nil then + layer[field] = valueFor((AUTHOR.layers[group] or {}).params, field, value) + end + elseif key == "walk" then local polygons = {} for text in string.gmatch(tostring(value), "[^;]+") do @@ -3398,6 +4366,8 @@ function forgeRoomSet(room, key, value) room.name = tostring(value) end end + -- A disc named or renamed is what the canvas shows from now on. + loadVideo() FORGE.dirty = true forgeRefresh() end @@ -3426,11 +4396,35 @@ local function typedValue(text, was) end +-- What is being typed: the field's value as text, whole and selected to begin with, so typing +-- replaces it and the arrows, Home, and End move a caret into it to edit it instead. changed +-- says whether anything was done to it, so a field passed over keeps its value. +function forgeEditingOf(key, was, set) + local text = (was ~= nil) and tostring(was) or "" + + return { key = key, text = text, cursor = #text, selected = (text ~= ""), changed = false, was = was, set = set } +end + + +-- What the text being typed is worth: nothing when it is empty, and false is a value. +function forgeEditValue() + if FORGE.editing.text == "" then + return nil + end + + return typedValue(FORGE.editing.text, FORGE.editing.was) +end + + function forgeEditCommit() if FORGE.editing == nil then return false end - FORGE.editing.set(FORGE.editing.key, typedValue(FORGE.editing.text, FORGE.editing.was)) + -- A field passed over with nothing done to it keeps its value: ENTER, ENTER, ENTER walks the + -- form without touching it. One emptied is set to nothing. + if FORGE.editing.changed then + FORGE.editing.set(FORGE.editing.key, forgeEditValue()) + end FORGE.editing = nil return true @@ -3478,17 +4472,12 @@ function forgeEditNext() end at = (at % #fields.names) + 1 FORGE.lastField = fields.names[at] - FORGE.editing = { key = fields.names[at], text = "", was = fields.get(fields.names[at]), set = fields.set } - -- A field that names a file offers the files under the game's directory; ESC on the list - -- leaves the field being typed instead. - if forgeFieldIsFile(fields.names[at]) then - local field = fields.names[at] - - forgePickBegin("a file for " .. field, forgeFileItems(), function(path) - fields.set(field, path) - FORGE.editing = nil - FORGE.message = field .. " = " .. path - end) + FORGE.editing = forgeEditingOf(fields.names[at], fields.get(fields.names[at]), fields.set) + -- A field with a fixed set of values -- a file, a flag, a key, a type, a look -- offers + -- them as a list, opened on the value it has (or on (none) when it has none) so ENTER + -- straight away keeps things as they are and the form can be walked through without + -- changing it. ESC on the list leaves the field being typed instead. + if forgeFieldList(fields, fields.names[at]) then return true end FORGE.message = "type a value for " .. fields.names[at] .. ", ENTER for the next, ESC to put it back" @@ -3504,21 +4493,113 @@ function forgeTyped(keysym) 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) + forgeEditBackspace() + elseif (keysym >= 32) and (keysym < 127) and not FORGE.textInput then + -- A keysym is the unshifted key. With the engine delivering typed text (onTextInput), + -- the characters come through forgeText instead, shifted as the person typed them. + forgeEditInsert(string.char(keysym)) else return false end - -- 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 +-- Puts text in at the caret, or in place of the selected value. +function forgeEditInsert(text) + local editing = FORGE.editing + + if editing.selected then + editing.text = text + editing.cursor = #text + editing.selected = false + else + editing.text = editing.text:sub(1, editing.cursor) .. text .. editing.text:sub(editing.cursor + 1) + editing.cursor = editing.cursor + #text + end + editing.changed = true + forgeTypedShow() +end + + +-- Takes out the character before the caret, or the whole selected value. +function forgeEditBackspace() + local editing = FORGE.editing + + if editing.selected then + editing.text = "" + editing.cursor = 0 + editing.selected = false + elseif editing.cursor > 0 then + editing.text = editing.text:sub(1, editing.cursor - 1) .. editing.text:sub(editing.cursor + 1) + editing.cursor = editing.cursor - 1 + else + return + end + editing.changed = true + forgeTypedShow() +end + + +-- Takes out the character after the caret, or the whole selected value: DELETE on a field just +-- entered empties it. +function forgeEditDelete() + local editing = FORGE.editing + + if editing.selected then + editing.text = "" + editing.cursor = 0 + editing.selected = false + editing.was = nil -- Emptied for good: ESC leaves it empty. + FORGE.message = editing.key .. " emptied" + elseif editing.cursor < #editing.text then + editing.text = editing.text:sub(1, editing.cursor) .. editing.text:sub(editing.cursor + 2) + else + return + end + editing.changed = true + forgeTypedShow() +end + + +-- Moves the caret: by one, or to an end. A selected value is dropped into a caret first. +function forgeEditCaret(to) + local editing = FORGE.editing + + editing.selected = false + editing.cursor = math.max(0, math.min(#editing.text, to)) + forgeRefresh() +end + + +-- Typed text, as the engine delivers it in full keyboard mode: what the keys produced with +-- their shifts and layout, so a capital or a quote is what was typed. Goes to the picker's filter +-- while a list is up, and to the field being edited otherwise. +function forgeText(text) + if FORGE.picker ~= nil then + FORGE.picker.filter = FORGE.picker.filter .. text + FORGE.picker.at = 1 + forgeRefresh() + return true + end + if FORGE.editing == nil then + return false + end + forgeEditInsert(text) + + return true +end + + +-- Shows the field as it is being typed: the value in the list is the value being entered. +function forgeTypedShow() + FORGE.editing.set(FORGE.editing.key, forgeEditValue()) + FORGE.message = "typing " .. FORGE.editing.key .. ": ENTER for the next, ESC to put it back" + forgeRefresh() +end + + -- ===== Files ================================================================================== -- -- Descriptions live in Forge's data directory, which is the one place a packed Forge can write. @@ -3532,17 +4613,17 @@ function forgeTemplate() title = "New game", layers = { { kind = "world2d", gravity = 1500 } }, vars = { score = 0 }, - kinds = { + types = { ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, + hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170, anchor = "feet" }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } }, rooms = { { name = "start", entities = { - { kind = "ground", id = "ground", x = 360, y = 440 }, - { kind = "hero", id = "hero", x = 120, y = 380 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "ground", id = "ground", x = 360, y = 440 }, + { type = "hero", id = "hero", x = 120, y = 420 }, + { type = "readout", id = "readout", x = 12, y = 12 } }, tracks = {} } }, @@ -3556,6 +4637,21 @@ function forgeTemplate() end +-- Whether a .game file is a packed game rather than a description: the database's own header. +local function packedGame(path) + local file = io.open(path, "rb") + local head + + if file == nil then + return false + end + head = file:read(15) + file:close() + + return head == "SQLite format 3" +end + + -- Every .game description in a directory, sorted. A directory that is not there lists nothing. local function descriptionsIn(directory) local found = {} @@ -3564,7 +4660,9 @@ local function descriptionsIn(directory) return found end for name in lfs.dir(directory) do - if string.sub(name, -5) == ".game" then + -- A description is Lua text; a packed game is a database with the same extension, and one + -- released beside the descriptions is not offered as one. + if (string.sub(name, -5) == ".game") and not packedGame(directory .. name) then found[#found + 1] = { name = string.sub(name, 1, -6), path = directory .. name } end end @@ -3582,6 +4680,11 @@ function forgeFiles() sample.sample = true files[#files + 1] = sample end + for _, tutorial in ipairs(descriptionsIn("Forge/tutorials/")) do + tutorial.sample = true + tutorial.tutorial = true + files[#files + 1] = tutorial + end files[#files + 1] = { name = "New game", fresh = true } return files @@ -3631,6 +4734,90 @@ function forgeOpen(entry) end +-- ===== The chooser ============================================================================ +-- +-- What Forge opens on: the descriptions in its data directory, the samples and tutorials inside +-- Forge.game, and a new game. Part of the library so a scene can draw it and press its keys. + +local MANUAL = singeGetDataPath() .. "Forge.pdf" -- The manual, put where a person can open it. +local MANUAL_SHOWN = 64 -- Characters of its path the chooser has room for. +local ROW_H = 22 +local LIST_X = 60 +local LIST_Y = 80 + + +function forgeChooserScan() + FORGE.chooser.files = forgeFiles() + FORGE.chooser.at = math.max(1, math.min(FORGE.chooser.at, #FORGE.chooser.files)) +end + + +-- The manual travels inside Forge.game, where nothing but Singe can read it. The first run +-- copies it out beside the descriptions, and the chooser says where. +function forgeChooserManual() + if (lfs.attributes(MANUAL, "mode") ~= "file") and (lfs.attributes("Forge/Forge.pdf", "mode") == "file") then + authorCopy("Forge/Forge.pdf", MANUAL) + end + + return (lfs.attributes(MANUAL, "mode") == "file") and MANUAL or nil +end + + +function forgeChooserDraw() + local y = LIST_Y + + colorBackground(18, 18, 26, 255) + overlayClear() + colorForeground(255, 207, 74, 255) + fontPrint(LIST_X, 30, "Forge") + colorForeground(154, 160, 180, 255) + fontPrint(LIST_X + 80, 30, "UP / DOWN choose ENTER open ESC leave") + if FORGE.chooser.manual ~= nil then + local shown = FORGE.chooser.manual + + -- A data directory given on the command line can be anywhere; the end of the path is + -- the part that says where the file is. + if #shown > MANUAL_SHOWN then + shown = "..." .. string.sub(shown, -MANUAL_SHOWN) + end + fontPrint(LIST_X, overlayGetHeight() - 40, "Manual: " .. shown) + end + for at, entry in ipairs(FORGE.chooser.files) do + local label = entry.name + + if entry.tutorial then + label = "tutorial " .. label .. " (a copy will be made)" + elseif entry.sample then + label = label .. " (a copy will be made)" + end + if at == FORGE.chooser.at then + colorForeground(192, 60, 150, 255) + fill(LIST_X - 10, y - 3, 410, ROW_H - 3) + colorForeground(255, 255, 255, 255) + else + colorForeground(230, 230, 238, 255) + end + fontPrint(LIST_X, y, label) + y = y + ROW_H + end +end + + +function forgeChooserKey(scancode) + if scancode == SCANCODE.DOWN.value then + FORGE.chooser.at = math.min(FORGE.chooser.at + 1, #FORGE.chooser.files) + elseif scancode == SCANCODE.UP.value then + FORGE.chooser.at = math.max(FORGE.chooser.at - 1, 1) + elseif scancode == SCANCODE.RETURN.value then + forgeOpen(FORGE.chooser.files[FORGE.chooser.at]) + elseif scancode == SCANCODE.ESCAPE.value then + singeQuit() + end +end + + + + -- ===== Forge as a game ======================================================================== -- -- Everything above is a library; this is what runs when Forge is started from the menu. A scene @@ -3643,73 +4830,16 @@ end if not FORGE_LIBRARY then local LAST_FILE = singeGetDataPath() .. "last.txt" -- What was open when play was pressed. - local MANUAL = singeGetDataPath() .. "Forge.pdf" -- The manual, put where a person can open it. - local MANUAL_SHOWN = 64 -- Characters of its path the chooser has room for. - local ROW_H = 22 - local LIST_X = 60 - local LIST_Y = 80 - - local chooser = { files = {}, at = 1 } local font = fontLoad("Singe/FreeSansBold.ttf", 15) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) - - local function chooserScan() - chooser.files = forgeFiles() - chooser.at = math.max(1, math.min(chooser.at, #chooser.files)) - end - - - -- The manual travels inside Forge.game, where nothing but Singe can read it. The first run - -- copies it out beside the descriptions, and the chooser says where. - local function extractManual() - if (lfs.attributes(MANUAL, "mode") ~= "file") and (lfs.attributes("Forge/Forge.pdf", "mode") == "file") then - authorCopy("Forge/Forge.pdf", MANUAL) - end - - return (lfs.attributes(MANUAL, "mode") == "file") and MANUAL or nil - end - - - local function chooserDraw() - local y = LIST_Y - - colorBackground(18, 18, 26, 255) - overlayClear() - colorForeground(255, 207, 74, 255) - fontPrint(LIST_X, 30, "Forge") - colorForeground(154, 160, 180, 255) - fontPrint(LIST_X + 80, 30, "UP / DOWN choose ENTER open ESC leave") - if chooser.manual ~= nil then - local shown = chooser.manual - - -- A data directory given on the command line can be anywhere; the end of the path is - -- the part that says where the file is. - if #shown > MANUAL_SHOWN then - shown = "..." .. string.sub(shown, -MANUAL_SHOWN) - end - fontPrint(LIST_X, overlayGetHeight() - 40, "Manual: " .. shown) - end - for at, entry in ipairs(chooser.files) do - local label = entry.name - - if entry.sample then - label = label .. " (a copy will be made)" - end - if at == chooser.at then - colorForeground(192, 60, 150, 255) - fill(LIST_X - 10, y - 3, 410, ROW_H - 3) - colorForeground(255, 255, 255, 255) - else - colorForeground(230, 230, 238, 255) - end - fontPrint(LIST_X, y, label) - y = y + ROW_H - end - end + -- Every key reaches the editor -- TAB, ENTER, the digits, the letters typed into a form -- + -- rather than only the ones controls.cfg maps to switches, which is all a game gets by + -- default. The mouse and a pad still arrive as switches. + keyboardSetMode(MODE_FULL) -- Saves, builds, and hands the game to the engine. Back here afterwards, on the same file. @@ -3748,19 +4878,6 @@ if not FORGE_LIBRARY then 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 @@ -3770,13 +4887,13 @@ if not FORGE_LIBRARY then return end forgeClose() - chooserScan() + forgeChooserScan() end function onOverlayUpdate() if FORGE.game == nil then - chooserDraw() + forgeChooserDraw() else local x, y = mouseGetPosition(0) @@ -3787,9 +4904,17 @@ if not FORGE_LIBRARY then end + -- Typed text, shifted and laid out as the person typed it; while it arrives, the keys' own + -- unshifted characters are not typed as well. + function onTextInput(text) + FORGE.textInput = true + forgeText(text) + end + + function onKeyPressed(keysym, scancode) if FORGE.game == nil then - chooserKey(scancode) + forgeChooserKey(scancode) elseif (scancode == SCANCODE.ESCAPE.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then editorEscape() elseif (scancode == SCANCODE.P.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then @@ -3802,13 +4927,17 @@ if not FORGE_LIBRARY then -- The pad walks the chooser; in the editor the left mouse button is the pointer's press. function onInputPressed(what) + -- A key masquerading as a switch (BACKSPACE as the mouse button) is handled as the key. + if authorSwitchIsKey(what) then + return + end if FORGE.game == nil then if what == SWITCH_DOWN then - chooserKey(SCANCODE.DOWN.value) + forgeChooserKey(SCANCODE.DOWN.value) elseif what == SWITCH_UP then - chooserKey(SCANCODE.UP.value) + forgeChooserKey(SCANCODE.UP.value) elseif (what == SWITCH_START1) or (what == SWITCH_BUTTON1) then - chooserKey(SCANCODE.RETURN.value) + forgeChooserKey(SCANCODE.RETURN.value) end elseif what == SWITCH_BUTTON3 then local x, y = mouseGetPosition(0) @@ -3819,7 +4948,7 @@ if not FORGE_LIBRARY then function onInputReleased(what) - if (FORGE.game ~= nil) and (what == SWITCH_BUTTON3) then + if (FORGE.game ~= nil) and (what == SWITCH_BUTTON3) and not authorSwitchIsKey(what) then forgeRelease() end end @@ -3832,9 +4961,9 @@ if not FORGE_LIBRARY then end - chooser.manual = extractManual() + FORGE.chooser.manual = forgeChooserManual() if not reopen() then - chooserScan() + forgeChooserScan() end end diff --git a/assets/Forge/kit/LICENSE b/assets/Forge/kit/LICENSE new file mode 100644 index 000000000..87389da1c --- /dev/null +++ b/assets/Forge/kit/LICENSE @@ -0,0 +1,8 @@ +The Forge tutorial kit. + +Everything here except fox.glb was drawn or synthesised by util/forgeKit.py in the +Singe source tree and is released under CC0: use it in your own games as you like. + +fox.glb is the Fox from the Khronos glTF sample models, CC-BY 4.0: model by PixelMannen, +rigging and animation by tomkranis, glTF conversion by AsoboStudio and scurest. +A game that ships it should carry this credit. diff --git a/assets/Forge/kit/README.txt b/assets/Forge/kit/README.txt new file mode 100644 index 000000000..e15386eb0 --- /dev/null +++ b/assets/Forge/kit/README.txt @@ -0,0 +1,11 @@ +The Forge tutorial kit, addressed from a game as Forge/kit/. + +hero.png a sprite sheet, 8 columns of 32 x 48, facing right: idle, walk x4, jump, fall, hit +enemy.png a sheet, 4 columns of 32 x 32, flapping +tiles.png a tile sheet, 4 columns of 32 x 32: grass, dirt, stone, brick +coin.png key.png crate.png door.png spike.png ship.png shot.png rock.png target.png stills +yard.png hall.png painted rooms, 720 x 480, for the adventures +jump.wav coin.wav shot.wav hit.wav click.wav door.wav clips +loop.ogg eight seconds of music that loops +clip.mkv twelve seconds of 720 x 480 video at 24 frames a second: a door opens at 3 s, a target rises at 6 s, a hand reaches at 9 s +fox.glb an animated model (see LICENSE) diff --git a/assets/Forge/kit/click.wav b/assets/Forge/kit/click.wav new file mode 100644 index 000000000..13277cc35 --- /dev/null +++ b/assets/Forge/kit/click.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95e21bd57bb4fc9bd7e251382e24a48ef63d7d01d98259008a19172c7b806208 +size 1808 diff --git a/assets/Forge/kit/clip.mkv b/assets/Forge/kit/clip.mkv new file mode 100644 index 000000000..0e2083b1b --- /dev/null +++ b/assets/Forge/kit/clip.mkv @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6529fae0625a5c2859ffdb1a61be9d87f80c7cc873d23219e79860f8d836aa9c +size 42549 diff --git a/assets/Forge/kit/coin.png b/assets/Forge/kit/coin.png new file mode 100644 index 000000000..d65f21d8d --- /dev/null +++ b/assets/Forge/kit/coin.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31a5213d47dd5b26894326c5f234a7dd0436f8f88ed0865f30fba2f0850ea528 +size 192 diff --git a/assets/Forge/kit/coin.wav b/assets/Forge/kit/coin.wav new file mode 100644 index 000000000..ea91e45c1 --- /dev/null +++ b/assets/Forge/kit/coin.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc1fcc85f50286868b66790c6609562d7cdcf4928740315a08521a81f84dcd18 +size 11510 diff --git a/assets/Forge/kit/crate.png b/assets/Forge/kit/crate.png new file mode 100644 index 000000000..4617418ca --- /dev/null +++ b/assets/Forge/kit/crate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f621a5bf9837ab5257d9a35f4a08c882abbbd412c5df7c29470c91a6a3724a4 +size 286 diff --git a/assets/Forge/kit/door.png b/assets/Forge/kit/door.png new file mode 100644 index 000000000..d830becdf --- /dev/null +++ b/assets/Forge/kit/door.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d41b3f4b9f18da5f341f011e53143d784da1789a2540ab804774d64c6af7d85 +size 231 diff --git a/assets/Forge/kit/door.wav b/assets/Forge/kit/door.wav new file mode 100644 index 000000000..ac7f28d40 --- /dev/null +++ b/assets/Forge/kit/door.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96551c6781e3e69e8276760228c9c8ac310607ce4f5776a9c29be870393b1138 +size 17684 diff --git a/assets/Forge/kit/enemy.png b/assets/Forge/kit/enemy.png new file mode 100644 index 000000000..58c8dc066 --- /dev/null +++ b/assets/Forge/kit/enemy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ecd914c6f41bc822bdc2bc5f4d83cea05ec4df857b81522988473a2b7a48cef +size 491 diff --git a/assets/Forge/kit/fox.glb b/assets/Forge/kit/fox.glb new file mode 100644 index 000000000..c68b86d3c --- /dev/null +++ b/assets/Forge/kit/fox.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7 +size 162852 diff --git a/assets/Forge/kit/hall.png b/assets/Forge/kit/hall.png new file mode 100644 index 000000000..7e6f004aa --- /dev/null +++ b/assets/Forge/kit/hall.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ee26b151e890dc8a4df2bae32542d684ff1fa7ecf268851f0723bba1761e58b +size 2527 diff --git a/assets/Forge/kit/hero.png b/assets/Forge/kit/hero.png new file mode 100644 index 000000000..278d8b3bc --- /dev/null +++ b/assets/Forge/kit/hero.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20bcb95bf065c3823b6217adfddedc3ab172eadf9fa81bda1e8b7b9829aea2ec +size 908 diff --git a/assets/Forge/kit/hit.wav b/assets/Forge/kit/hit.wav new file mode 100644 index 000000000..88c28f951 --- /dev/null +++ b/assets/Forge/kit/hit.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f631d2c98a63cc059fdab4b978ee89133e6859ab6a1653e22615c40e4cba407e +size 13274 diff --git a/assets/Forge/kit/jump.wav b/assets/Forge/kit/jump.wav new file mode 100644 index 000000000..325c81ab1 --- /dev/null +++ b/assets/Forge/kit/jump.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f907f366480e401d5456cec56a44744f96695b33bfc371aa969ee5e6c2dac866 +size 11068 diff --git a/assets/Forge/kit/key.png b/assets/Forge/kit/key.png new file mode 100644 index 000000000..e016f22c3 --- /dev/null +++ b/assets/Forge/kit/key.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb2282ba0c2a8e519463ca00aa7ce76bdde6356758ff3b311cfba973a792e2ec +size 161 diff --git a/assets/Forge/kit/loop.ogg b/assets/Forge/kit/loop.ogg new file mode 100644 index 000000000..4d888f3d7 --- /dev/null +++ b/assets/Forge/kit/loop.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d27304c178df218715978129e2d7a5ead7decd6a1d8f1dbf97974c8ce92708b +size 50059 diff --git a/assets/Forge/kit/rock.png b/assets/Forge/kit/rock.png new file mode 100644 index 000000000..b4d27976d --- /dev/null +++ b/assets/Forge/kit/rock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57db8cbb564b51e472e8ed23446762fb88e2791b5344c718030738e8deb97a80 +size 297 diff --git a/assets/Forge/kit/ship.png b/assets/Forge/kit/ship.png new file mode 100644 index 000000000..104559378 --- /dev/null +++ b/assets/Forge/kit/ship.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c6682153b641100c81d881c22eaa1af284643456a5affb538f59cb76406541d +size 314 diff --git a/assets/Forge/kit/shot.png b/assets/Forge/kit/shot.png new file mode 100644 index 000000000..57d0771d2 --- /dev/null +++ b/assets/Forge/kit/shot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fe68847b2d223e14c1023570de600afdf567e1b09bd403172162b9bda125afe +size 120 diff --git a/assets/Forge/kit/shot.wav b/assets/Forge/kit/shot.wav new file mode 100644 index 000000000..58c1122ce --- /dev/null +++ b/assets/Forge/kit/shot.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc12710ae5622938d4f28c4a5a86655c75d40bf29c5c574bbd6793826028f2c0 +size 6658 diff --git a/assets/Forge/kit/spike.png b/assets/Forge/kit/spike.png new file mode 100644 index 000000000..6dd7c5a64 --- /dev/null +++ b/assets/Forge/kit/spike.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a6d8b67491352cb356c6d8d20480492bb4a9847c6e2ad8735225c49779a17b5 +size 153 diff --git a/assets/Forge/kit/target.png b/assets/Forge/kit/target.png new file mode 100644 index 000000000..73cdfb211 --- /dev/null +++ b/assets/Forge/kit/target.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9a95bcd6a22970ad1f9626cd84fddd0db3de5d50a33fc8250b4dbae0776e937 +size 543 diff --git a/assets/Forge/kit/tiles.png b/assets/Forge/kit/tiles.png new file mode 100644 index 000000000..43629b054 --- /dev/null +++ b/assets/Forge/kit/tiles.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5b127780dcee14d08cc1a20d93362678e9c705d7a019573b7bff0a19b03a1c5 +size 411 diff --git a/assets/Forge/kit/yard.png b/assets/Forge/kit/yard.png new file mode 100644 index 000000000..b431512e3 --- /dev/null +++ b/assets/Forge/kit/yard.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:643dfcff7892e4b77914989d1a5405694a8e68087f1523b3f87b50ea75e28067 +size 4431 diff --git a/assets/Forge/tutorials/01-tenMinutes.game b/assets/Forge/tutorials/01-tenMinutes.game new file mode 100644 index 000000000..5cea7b2ec --- /dev/null +++ b/assets/Forge/tutorials/01-tenMinutes.game @@ -0,0 +1,76 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + layers = { + { gravity = 1500, kind = "world2d" } + }, + rooms = { + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "hero", type = "hero", x = 200, y = 420 }, + { id = "readout", type = "readout", x = 12, y = 12 } + }, + name = "start", + tracks = {} + } + }, + rules = { + { + act = { + { "run", direction = -1, entity = "hero" } + }, + note = "Run left", + on = "frame", + when = { + { "keyHeld", key = "LEFT" } + } + }, + { + act = { + { "run", direction = 1, entity = "hero" } + }, + note = "Run right", + on = "frame", + when = { + { "keyHeld", key = "RIGHT" } + } + }, + { + act = { + { "jump", entity = "hero" } + }, + note = "Jump, with ground underfoot", + on = "frame", + when = { + { "keyHeld", key = "SPACE" }, + { "onGround", entity = "hero" } + } + }, + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score" } + }, + note = "The readout, every frame", + on = "frame" + } + }, + title = "New game", + types = { + ground = { + behaviours = { + { kind = "solid" } + }, + look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } + }, + hero = { + behaviours = { + { jump = 620, kind = "platformer", speed = 260 } + }, + look = { anchor = "feet", b = 240, g = 90, h = 44, kind = "box", r = 90, w = 24 } + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + } + }, + vars = { score = 0 } +} diff --git a/assets/Forge/tutorials/02-platformer.game b/assets/Forge/tutorials/02-platformer.game new file mode 100644 index 000000000..d88196432 --- /dev/null +++ b/assets/Forge/tutorials/02-platformer.game @@ -0,0 +1,144 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + layers = { + { gravity = 1500, kind = "world2d" } + }, + rooms = { + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "hero", type = "hero", x = 200, y = 420 }, + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "coin", type = "coin", x = 300, y = 392 }, + { id = "coin1", type = "coin", x = 420, y = 392 }, + { id = "coin2", type = "coin", x = 560, y = 300 }, + { id = "spike", type = "spike", x = 640, y = 420 }, + { id = "door", type = "door", x = 700, y = 420 } + }, + name = "start", + tracks = {} + }, + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "coin", type = "coin", x = 400, y = 392 } + }, + name = "cave", + tracks = {} + } + }, + rules = { + { + act = { + { "run", direction = -1, entity = "hero" } + }, + note = "Run left", + on = "frame", + when = { + { "keyHeld", key = "LEFT" } + } + }, + { + act = { + { "run", direction = 1, entity = "hero" } + }, + note = "Run right", + on = "frame", + when = { + { "keyHeld", key = "RIGHT" } + } + }, + { + act = { + { "jump", entity = "hero" } + }, + note = "Jump, with ground underfoot", + on = "frame", + when = { + { "keyHeld", key = "SPACE" }, + { "onGround", entity = "hero" } + } + }, + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + a = "coin", + act = { + { "addScore", amount = 10 }, + { "destroy", entity = "self" }, + { "playSound", file = "Forge/kit/coin.wav" } + }, + b = "hero", + note = "Take a coin", + on = "enter", + when = {} + }, + { + a = "spike", + act = { + { "moveTo", entity = "hero", x = 200, y = 380 }, + { "playSound", file = "Forge/kit/hit.wav" } + }, + b = "hero", + note = "Spikes hurt", + on = "enter", + when = {} + }, + { + a = "door", + act = { + { "goTo", entity = "hero", room = "cave", x = 60, y = 380 } + }, + b = "hero", + note = "Through the door", + on = "enter", + when = {} + } + }, + title = "New game", + types = { + coin = { + behaviours = { + { kind = "trigger" } + }, + look = { file = "Forge/kit/coin.png", kind = "sprite" }, + vars = {} + }, + door = { + behaviours = { + { kind = "trigger" } + }, + look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, + vars = {} + }, + ground = { + behaviours = { + { kind = "solid" } + }, + look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } + }, + hero = { + behaviours = { + { jump = 620, kind = "platformer", speed = 260 } + }, + look = { anchor = "feet", b = 240, g = 90, h = 44, kind = "box", r = 90, w = 24 } + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + spike = { + behaviours = { + { kind = "trigger" } + }, + look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" }, + vars = {} + } + }, + vars = { score = 0 } +} diff --git a/assets/Forge/tutorials/03-sprites.game b/assets/Forge/tutorials/03-sprites.game new file mode 100644 index 000000000..dfee4ef43 --- /dev/null +++ b/assets/Forge/tutorials/03-sprites.game @@ -0,0 +1,156 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + layers = { + { gravity = 1500, kind = "world2d" } + }, + rooms = { + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "hero", type = "hero", x = 200, y = 420 }, + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "coin", type = "coin", x = 300, y = 392 }, + { id = "coin1", type = "coin", x = 420, y = 392 }, + { id = "coin2", type = "coin", x = 560, y = 300 }, + { id = "spike", type = "spike", x = 640, y = 420 }, + { id = "door", type = "door", x = 700, y = 420 } + }, + name = "start", + tracks = {} + }, + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "coin", type = "coin", x = 400, y = 392 } + }, + name = "cave", + tracks = {} + } + }, + rules = { + { + act = { + { "run", direction = -1, entity = "hero" } + }, + note = "Run left", + on = "frame", + when = { + { "keyHeld", key = "LEFT" } + } + }, + { + act = { + { "run", direction = 1, entity = "hero" } + }, + note = "Run right", + on = "frame", + when = { + { "keyHeld", key = "RIGHT" } + } + }, + { + act = { + { "jump", entity = "hero" }, + { "playSound", file = "Forge/kit/jump.wav" } + }, + note = "Jump, with ground underfoot", + on = "frame", + when = { + { "onGround", entity = "hero" }, + { "keyPressed", key = "SPACE" } + } + }, + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + a = "coin", + act = { + { "addScore", amount = 10 }, + { "destroy", entity = "self" }, + { "playSound", file = "Forge/kit/coin.wav" } + }, + b = "hero", + note = "Take a coin", + on = "enter", + when = {} + }, + { + a = "spike", + act = { + { "moveTo", entity = "hero", x = 200, y = 380 }, + { "playSound", file = "Forge/kit/hit.wav" } + }, + b = "hero", + note = "Spikes hurt", + on = "enter", + when = {} + }, + { + a = "door", + act = { + { "goTo", entity = "hero", room = "cave", x = 60, y = 380 } + }, + b = "hero", + note = "Through the door", + on = "enter", + when = {} + }, + { + act = { + { "playMusic", file = "Forge/kit/loop.ogg" } + }, + note = "Music", + on = "roomStart", + when = {} + } + }, + title = "New game", + types = { + coin = { + behaviours = { + { kind = "trigger" }, + { kind = "spin", rate = 180 } + }, + look = { file = "Forge/kit/coin.png", kind = "sprite" }, + vars = {} + }, + door = { + behaviours = { + { kind = "trigger" } + }, + look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, + vars = {} + }, + ground = { + behaviours = { + { kind = "solid" } + }, + look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } + }, + hero = { + behaviours = { + { jump = 620, kind = "platformer", speed = 260 }, + { fps = 10, kind = "frames", states = "idle=1-1, walk=2-5, jump=6-6, fall=7-7" } + }, + look = { file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + spike = { + behaviours = { + { kind = "trigger" } + }, + look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" }, + vars = {} + } + }, + vars = { score = 0 } +} diff --git a/assets/Forge/tutorials/04-shmup.game b/assets/Forge/tutorials/04-shmup.game new file mode 100644 index 000000000..bc48f7141 --- /dev/null +++ b/assets/Forge/tutorials/04-shmup.game @@ -0,0 +1,135 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + types = { + hive = { + behaviours = { + { every = 0.6, kind = "spawner", max = 6, spawns = "rock", total = 12 } + }, + look = { kind = "none" }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + rock = { + behaviours = { + { kind = "drift", vy = 90 }, + { kind = "health", max = 2 }, + { death = "Forge/kit/hit.wav", kind = "sound" } + }, + look = { file = "Forge/kit/rock.png", kind = "sprite" }, + vars = { health = 2 } + }, + ship = { + behaviours = { + { down = "DOWN", fire = "SPACE", kind = "keys", left = "LEFT", right = "RIGHT", up = "UP" }, + { clamp = true, kind = "mover", speed = 260 }, + { kind = "shooter", offsetY = -14, rate = 0.15, spawns = "shot" }, + { keep = true, kind = "health", max = 1 }, + { hit = "Forge/kit/hit.wav", kind = "sound" } + }, + look = { file = "Forge/kit/ship.png", kind = "sprite" }, + vars = { health = 1 } + }, + shot = { + behaviours = { + { kind = "projectile", life = 3, vy = -520 }, + { kind = "sound", spawn = "Forge/kit/shot.wav" } + }, + look = { file = "Forge/kit/shot.png", kind = "sprite" }, + vars = {} + } + }, + layers = { + { kind = "overlay" } + }, + rooms = { + { + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "ship", type = "ship", x = 360, y = 420 }, + { id = "hive", type = "hive", x = 360, y = 20 }, + { id = "hive1", type = "hive", x = 560, y = 20 } + }, + name = "start", + tracks = {} + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + a = "shot", + act = { + { "destroy", entity = "self" }, + { "damage", amount = 1, entity = "other" }, + { "addScore", amount = 10 } + }, + b = "rock", + note = "A shot lands on a rock", + on = "collision", + when = {} + }, + { + act = { + { "emit", b = 60, count = 24, entity = "self", g = 120, r = 255 }, + { "addScore", amount = 50 } + }, + type = "rock", + note = "A rock dies", + on = "death", + when = {} + }, + { + a = "rock", + act = { + { "destroy", entity = "self" }, + { "damage", amount = 1, entity = "other" } + }, + b = "ship", + note = "A rock rams the ship", + on = "collision", + when = {} + }, + { + act = { + { "addVar", amount = -1, name = "lives" }, + { "flash", b = 60, g = 60, r = 255, seconds = 0.3 }, + { "setVar", entity = "self", name = "health", value = "1" }, + { "moveTo", entity = "self", x = 360, y = 420 } + }, + type = "ship", + note = "The ship is lost", + on = "death", + when = {} + }, + { + act = { + { "gameOver" } + }, + note = "Out of lives", + on = "frame", + when = { + { "test", expr = "lives <= 0" } + } + }, + { + act = { + { "destroy", entity = "self" } + }, + each = "rock", + note = "A rock slips past the bottom", + on = "frame", + when = { + { "test", expr = "self.y > 500" } + } + } + }, + title = "Rocks", + vars = { lives = 3, score = 0 } +} diff --git a/assets/Forge/tutorials/05-qte.game b/assets/Forge/tutorials/05-qte.game new file mode 100644 index 000000000..b708540fd --- /dev/null +++ b/assets/Forge/tutorials/05-qte.game @@ -0,0 +1,140 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + types = { + prompt = { + behaviours = {}, + look = { b = 80, g = 220, kind = "text", r = 255 }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + verdict = { + behaviours = {}, + look = { b = 180, g = 255, kind = "text", r = 160 }, + vars = {} + } + }, + layers = { + { file = "Forge/kit/clip.mkv", kind = "disc" }, + { kind = "overlay" } + }, + rooms = { + { + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "prompt", type = "prompt", x = 250, y = 60 }, + { id = "verdict", type = "verdict", x = 250, y = 100 } + }, + name = "start", + tracks = {} + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + act = { + { "setText", entity = "prompt", text = "\"PRESS!\"" } + }, + note = "Window one is open", + on = "frame", + when = { + { "discBetween", from = 72, to = 120 } + } + }, + { + act = { + { "setVar", name = "hitone", value = true }, + { "addScore", amount = 250 }, + { "setText", entity = "verdict", text = "\"HIT\"" } + }, + note = "Window one answered in time", + on = "frame", + when = { + { "discBetween", from = 72, to = 120 }, + { "keyPressed", key = "SPACE" }, + { "once", tag = "one" } + } + }, + { + act = { + { "addVar", amount = -1, name = "lives" }, + { "setText", entity = "verdict", text = "\"MISS\"" }, + { "setText", entity = "prompt", text = "\"\"" } + }, + frame = 120, + note = "Window one closed unanswered", + on = "frameReached", + when = { + { "test", expr = "not hitone" } + } + }, + { + act = { + { "setText", entity = "prompt", text = "\"UP!\"" } + }, + note = "Window two is open", + on = "frame", + when = { + { "discBetween", from = 150, to = 200 } + } + }, + { + act = { + { "setVar", name = "hittwo", value = true }, + { "addScore", amount = 250 }, + { "setText", entity = "verdict", text = "\"HIT\"" } + }, + note = "Window two answered in time", + on = "frame", + when = { + { "discBetween", from = 150, to = 200 }, + { "keyPressed", key = "UP" }, + { "once", tag = "two" } + } + }, + { + act = { + { "addVar", amount = -1, name = "lives" }, + { "setText", entity = "verdict", text = "\"MISS\"" }, + { "setText", entity = "prompt", text = "\"\"" } + }, + frame = 200, + note = "Window two closed unanswered", + on = "frameReached", + when = { + { "test", expr = "not hittwo" } + } + }, + { + act = { + { "setVar", name = "hitone", value = false }, + { "setVar", name = "hittwo", value = false }, + { "setText", entity = "verdict", text = "\"\"" }, + { "discTo", frame = 1 } + }, + frame = 280, + note = "The clip runs out: round again", + on = "frameReached", + when = {} + }, + { + act = { + { "gameOver" } + }, + note = "Out of lives", + on = "frame", + when = { + { "test", expr = "lives <= 0" } + } + } + }, + title = "Moments", + vars = { lives = 3, score = 0 } +} diff --git a/assets/Forge/tutorials/06-gun.game b/assets/Forge/tutorials/06-gun.game new file mode 100644 index 000000000..f7f216ae2 --- /dev/null +++ b/assets/Forge/tutorials/06-gun.game @@ -0,0 +1,115 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + types = { + gun = { + behaviours = { + { ammo = 6, kind = "gun", player = 1, reload = "offscreen" } + }, + look = { kind = "none" }, + vars = { ammo = 6 } + }, + hand = { + behaviours = { + { kind = "hitbox", track = "hand" } + }, + look = { kind = "none" }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + target = { + behaviours = { + { kind = "hitbox", track = "target" }, + { kind = "health", max = 1 } + }, + look = { kind = "none" }, + vars = { health = 1 } + } + }, + layers = { + { file = "Forge/kit/clip.mkv", kind = "disc" }, + { kind = "overlay" } + }, + rooms = { + { + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "gun", type = "gun", x = 30, y = 450 }, + { id = "target", type = "target", x = 450, y = 210 }, + { id = "hand", type = "hand", x = 600, y = 210 } + }, + name = "start", + tracks = { + { + boxes = { + { at = 150, h = 90, w = 90, x = 405, y = 165 }, + { at = 210, h = 90, w = 90, x = 405, y = 165 } + }, + key = "frame", + name = "target" + }, + { + boxes = { + { at = 220, h = 105, w = 240, x = 480, y = 157 }, + { at = 260, h = 105, w = 240, x = 480, y = 157 } + }, + key = "frame", + name = "hand" + } + } + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score .. \" ammo \" .. gun.ammo .. \" misses \" .. misses" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + act = { + { "addScore", amount = 100 }, + { "damage", amount = 1, entity = "self" }, + { "playSound", file = "Forge/kit/hit.wav" } + }, + type = "target", + note = "The target is hit", + on = "hit", + when = {} + }, + { + act = { + { "addScore", amount = -200 }, + { "flash", b = 40, g = 40, r = 255, seconds = 0.3 } + }, + type = "hand", + note = "The hand is hit", + on = "hit", + when = {} + }, + { + act = { + { "addVar", amount = 1, name = "misses" }, + { "playSound", file = "Forge/kit/shot.wav" } + }, + note = "A shot at nothing", + on = "miss", + when = { + { "test", expr = "not event.offscreen" } + } + }, + { + act = { + { "say", seconds = 1, text = "\"DRAW!\"" } + }, + frame = 150, + note = "Draw!", + on = "frameReached", + when = {} + } + }, + title = "Draw", + vars = { misses = 0, score = 0 } +} diff --git a/assets/Forge/tutorials/07-adventure.game b/assets/Forge/tutorials/07-adventure.game new file mode 100644 index 000000000..9ddbefb5b --- /dev/null +++ b/assets/Forge/tutorials/07-adventure.game @@ -0,0 +1,244 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + dialogues = { + guard = { + nodes = { + ok = { + choices = {}, + text = "Then go ahead.", + who = "Guard" + }, + start = { + choices = { + { next = "why", text = "Why not?" }, + { text = "Fine." } + }, + text = "Nobody passes.", + who = "Guard" + }, + why = { + choices = { + { + act = { + { "setVar", name = "allowed", value = true } + }, + next = "ok", + text = "I have a key.", + when = "has(\"key\")" + }, + { text = "I see." } + }, + text = "The door is locked, and I have no key.", + who = "Guard" + } + }, + start = "start" + } + }, + types = { + backdrop = { + behaviours = {}, + look = { file = "Forge/kit/yard.png", kind = "sprite" }, + vars = {} + }, + door = { + behaviours = { + { kind = "hotspot", name = "door", walkX = 590, walkY = 330 } + }, + look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, + vars = {} + }, + guard = { + behaviours = { + { kind = "hotspot", name = "guard", walkX = 480, walkY = 360 } + }, + look = { anchor = "feet", faces = "left", file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, + vars = {} + }, + hero = { + behaviours = { + { kind = "walker", speed = 160 }, + { fps = 10, kind = "frames", states = "idle=1-1, walk=2-5" } + }, + look = { anchor = "feet", file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, + vars = {} + }, + key = { + behaviours = { + { kind = "hotspot", name = "key", walkX = 200, walkY = 400 } + }, + look = { file = "Forge/kit/key.png", kind = "sprite" }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + } + }, + layers = { + { kind = "overlay" } + }, + rooms = { + { + depthSort = true, + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "backdrop", type = "backdrop", x = 360, y = 240 }, + { id = "hero", type = "hero", x = 150, y = 440 }, + { id = "door", type = "door", x = 590, y = 300 }, + { id = "key", type = "key", x = 200, y = 380 }, + { id = "guard", type = "guard", x = 520, y = 340 } + }, + name = "yard", + scaleBy = { + { scale = 0.6, y = 300 }, + { scale = 1.0, y = 460 } + }, + tracks = {}, + walk = { + { 60, 320, 660, 320, 700, 460, 40, 460 } + } + }, + { + depthSort = true, + entities = { + { id = "backdrop", type = "backdrop", x = 360, y = 240 }, + { id = "readout", type = "readout", x = 12, y = 12 } + }, + name = "hall", + tracks = {}, + walk = { + { 40, 300, 680, 300, 700, 460, 40, 460 } + } + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "(sentence or \"\") .. \" score \" .. score" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + act = { + { "walkToPointer", entity = "hero", player = 1 } + }, + interrupt = true, + note = "A click on the floor walks there", + on = "pressed", + switch = "SWITCH_BUTTON3", + when = {} + }, + { + act = { + { "say", seconds = 1, text = "\"It is a \" .. event.target .. \".\"" } + }, + note = "Look at anything", + on = "verb", + verb = "look", + when = {} + }, + { + act = { + { "walkToHotspot", entity = "hero", target = "self" }, + { "give", item = "key" }, + { "addScore", amount = 1 }, + { "destroy", entity = "self" } + }, + note = "Take the key", + on = "verb", + target = "key", + verb = "take", + when = {} + }, + { + act = { + { "walkToHotspot", entity = "hero", target = "self" }, + { "face", entity = "hero", target = "self" }, + { "talk", dialogue = "guard" } + }, + note = "Talk to the guard", + on = "verb", + target = "guard", + verb = "talk", + when = {} + }, + { + act = { + { "say", seconds = 1, text = "\"The guard shakes his head.\"" } + }, + note = "The door, while the guard objects", + on = "verb", + target = "door", + verb = "open", + when = { + { "test", expr = "not allowed" } + } + }, + { + act = { + { "walkToHotspot", entity = "hero", target = "self" }, + { "playSound", file = "Forge/kit/door.wav" }, + { "fade", out = true, seconds = 0.5 }, + { "goTo", entity = "hero", room = "hall", x = 120, y = 420 }, + { "fade", out = false, seconds = 0.5 }, + { "addScore", amount = 1 } + }, + controls = false, + note = "The door opens", + on = "verb", + target = "door", + verb = "open", + when = { + { "test", expr = "allowed" } + } + }, + { + act = { + { "say", seconds = 1, text = "\"That does not work.\"" } + }, + note = "Anything else", + on = "verb", + when = {} + }, + { + act = { + { "setVerb", verb = "look" } + }, + key = "L", + note = "The verb keys", + on = "pressed", + when = {} + }, + { + act = { + { "setVerb", verb = "take" } + }, + key = "T", + note = "Take", + on = "pressed", + when = {} + }, + { + act = { + { "setVerb", verb = "open" } + }, + key = "O", + note = "Open", + on = "pressed", + when = {} + }, + { + act = { + { "setVerb", verb = "talk" } + }, + key = "K", + note = "Talk", + on = "pressed", + when = {} + } + }, + title = "The Yard", + vars = { allowed = false, score = 0 }, + verbs = { "walk", "look", "take", "use", "open", "talk" } +} diff --git a/assets/Forge/tutorials/08-scene.game b/assets/Forge/tutorials/08-scene.game new file mode 100644 index 000000000..4b115192a --- /dev/null +++ b/assets/Forge/tutorials/08-scene.game @@ -0,0 +1,129 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + types = { + camera = { + behaviours = { + { kind = "camera", lag = 0.2, lookY = 0.6, mode = "follow", target = "hero", x = 0, y = 3, z = 7 } + }, + look = { kind = "none" }, + vars = {} + }, + floor = { + behaviours = { + { kind = "solid" } + }, + look = { b = 90, d = 12, g = 110, h = 0.4, kind = "mesh", r = 90, shape = "box", w = 30 }, + vars = {} + }, + hero = { + behaviours = { + { down = "DOWN", kind = "keys", left = "LEFT", right = "RIGHT", up = "UP" }, + { height = 0.9, jump = 6.5, kind = "character", radius = 0.3, speed = 4 }, + { idle = "Survey", kind = "animator", walk = "Walk" } + }, + look = { d = 0.6, file = "Forge/kit/fox.glb", h = 0.9, kind = "model", scale = 0.008, w = 0.6 }, + vars = {} + }, + ledge = { + behaviours = { + { kind = "solid" } + }, + look = { b = 70, d = 4, g = 90, h = 0.4, kind = "mesh", r = 110, shape = "box", w = 4 }, + vars = {} + }, + prize = { + behaviours = { + { kind = "trigger" } + }, + look = { b = 60, g = 200, kind = "mesh", r = 255, shape = "sphere", w = 0.6 }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + } + }, + layers = { + { fov = 55, kind = "scene3d" }, + { kind = "overlay" } + }, + rooms = { + { + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "floor", type = "floor", x = 0.0, y = -0.2, z = 0.0 }, + { id = "ledge", type = "ledge", x = 6.0, y = 1.2, z = 0.0 }, + { id = "prize", type = "prize", x = 6.0, y = 2.0, z = 0.0 }, + { id = "hero", type = "hero", x = -4.0, y = 0.5, z = 0.0 }, + { id = "camera", type = "camera", x = -4.0, y = 3.0, z = 7.0 } + }, + name = "start", + tracks = {} + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + act = { + { "setState", entity = "self", state = "walk" } + }, + each = "hero", + note = "Walking is a state the animator plays", + on = "frame", + when = { + { "test", expr = "self.dx != 0 or self.dy != 0" } + } + }, + { + act = { + { "setState", entity = "self", state = "idle" } + }, + each = "hero", + note = "Standing still", + on = "frame", + when = { + { "test", expr = "self.dx == 0 and self.dy == 0" } + } + }, + { + act = { + { "jump", entity = "hero" } + }, + note = "Jump, with ground underfoot", + on = "frame", + when = { + { "keyPressed", key = "SPACE" }, + { "onGround", entity = "hero" } + } + }, + { + a = "prize", + act = { + { "destroy", entity = "self" }, + { "addScore", amount = 100 }, + { "playSound", file = "Forge/kit/coin.wav" } + }, + b = "hero", + note = "The prize is taken", + on = "enter", + when = {} + }, + { + act = { + { "moveTo", entity = "hero", x = -4, y = 0.5, z = 0 } + }, + note = "Fallen off the world", + on = "frame", + when = { + { "test", expr = "hero.y < -5" } + } + } + }, + title = "Field", + vars = { score = 0 } +} diff --git a/assets/Forge/tutorials/09-rail.game b/assets/Forge/tutorials/09-rail.game new file mode 100644 index 000000000..0a33baf7f --- /dev/null +++ b/assets/Forge/tutorials/09-rail.game @@ -0,0 +1,215 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + types = { + camera = { + behaviours = { + { kind = "camera", mode = "rail", track = "rail" } + }, + look = { kind = "none" }, + vars = {} + }, + floor = { + behaviours = { + { kind = "solid" } + }, + look = { b = 90, d = 60, g = 75, h = 0.2, kind = "mesh", r = 70, shape = "box", w = 40 }, + vars = {} + }, + gun = { + behaviours = { + { ammo = 6, kind = "gun", player = 1, reload = "offscreen" } + }, + look = { kind = "none" }, + vars = { ammo = 6 } + }, + painting = { + behaviours = {}, + look = { b = 255, d = 0.2, g = 255, h = 16, kind = "mesh", r = 255, shape = "box", texture = "Forge/kit/yard.png", unlit = true, w = 24 }, + vars = {} + }, + pillar = { + behaviours = {}, + look = { d = 1.2, h = 8, kind = "mesh", occluder = true, shape = "box", w = 1.2 }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + zombie = { + behaviours = { + { kind = "health", linger = 2, max = 2, ragdoll = true }, + { kind = "seek", speed = 1.6, stopAt = 2.2, target = "camera" }, + { attack = "Run", idle = "Survey", kind = "animator", walk = "Walk" }, + { kind = "target" }, + { after = 1.5, kind = "timer", name = "bite", start = false } + }, + look = { d = 1.4, file = "Forge/kit/fox.glb", h = 0.9, kind = "model", scale = 0.01, w = 0.8 }, + vars = { health = 2 } + } + }, + layers = { + { fov = 60, kind = "scene3d" }, + { kind = "overlay" } + }, + rooms = { + { + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "floor", type = "floor", x = 0.0, y = -0.1, z = -20.0 }, + { id = "painting", type = "painting", x = 0.0, y = 6.0, z = -30.0 }, + { id = "pillar", type = "pillar", x = 0.0, y = 4.0, z = -22.0 }, + { id = "camera", type = "camera", x = 0.0, y = 1.6, z = 0.0 }, + { id = "gun", type = "gun", x = 0.0, y = 0.0, z = 0.0 } + }, + name = "start", + tracks = { + { + key = "time", + name = "rail", + points = { + { + at = 0, + look = { 0, 1.2, -10 }, + stop = "nil", + x = 0, + y = 1.6, + z = 0 + }, + { + at = 4, + look = { 0, 1.2, -16 }, + stop = "gate", + x = 0, + y = 1.6, + z = -6 + }, + { + at = 10, + look = { 2, 1.2, -24 }, + stop = "pillar", + x = 2, + y = 1.6, + z = -14 + }, + { + at = 14, + look = { 2, 1.2, -28 }, + stop = "nil", + x = 2, + y = 1.6, + z = -18 + } + } + } + } + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score .. \" health \" .. health .. \" ammo \" .. gun.ammo" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + act = { + { "spawn", type = "zombie", x = -2, y = 0, z = -14 }, + { "spawn", type = "zombie", x = 2, y = 0, z = -15 } + }, + name = "gate", + note = "The gate: two come out of the dark", + on = "stopped", + when = {} + }, + { + act = { + { "spawn", type = "zombie", x = -1, y = 0, z = -23 }, + { "spawn", type = "zombie", x = 0, y = 0, z = -26 }, + { "spawn", type = "zombie", x = 4, y = 0, z = -24 } + }, + name = "pillar", + note = "The pillar: three more, one from behind it", + on = "stopped", + when = {} + }, + { + act = { + { "setState", entity = "self", state = "walk" } + }, + type = "zombie", + note = "A zombie walks the moment it is made", + on = "spawn", + when = {} + }, + { + act = { + { "damage", amount = 1, entity = "self" }, + { "addScore", amount = 100 }, + { "playSound", file = "Forge/kit/hit.wav" } + }, + type = "zombie", + note = "A shot lands", + on = "hit", + when = {} + }, + { + act = { + { "setState", entity = "self", state = "attack" }, + { "timerStart", after = 1.5, entity = "self", name = "bite" } + }, + type = "zombie", + note = "It reaches the camera and rears up", + on = "arrived", + when = {} + }, + { + act = { + { "addVar", amount = -1, name = "health" }, + { "flash", b = 40, g = 40, r = 255, seconds = 0.3 }, + { "shake", amount = 0.3, seconds = 0.4 }, + { "timerStart", after = 1.5, entity = "self", name = "bite" } + }, + type = "zombie", + name = "bite", + note = "The bite lands unless it was killed in time", + on = "timer", + when = { + { "inState", entity = "self", state = "attack" } + } + }, + { + act = { + { "pathNext", entity = "camera" } + }, + note = "The stop is clear: move on", + on = "frame", + when = { + { "waiting", entity = "camera" }, + { "test", expr = "count(\"zombie\") == 0" } + } + }, + { + act = { + { "say", seconds = 2, text = "\"YOU ARE DEAD\"" }, + { "gameOver" } + }, + note = "Out of health", + on = "frame", + when = { + { "test", expr = "health <= 0" }, + { "once", scope = "game", tag = "over" } + } + }, + { + act = { + { "say", seconds = 2, text = "\"STAGE CLEAR\"" } + }, + note = "The end of the rail", + on = "railEnd", + when = {} + } + }, + title = "The Dead Yard", + vars = { health = 3, score = 0 } +} diff --git a/assets/Forge/tutorials/10-release.game b/assets/Forge/tutorials/10-release.game new file mode 100644 index 000000000..434886528 --- /dev/null +++ b/assets/Forge/tutorials/10-release.game @@ -0,0 +1,193 @@ +-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. +return { + layers = { + { gravity = 1500, kind = "world2d" }, + { kind = "bezel" } + }, + rooms = { + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "hero", type = "hero", x = 200, y = 420 }, + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "coin", type = "coin", x = 300, y = 392 }, + { id = "coin1", type = "coin", x = 420, y = 392 }, + { id = "coin2", type = "coin", x = 560, y = 300 }, + { id = "spike", type = "spike", x = 640, y = 420 }, + { id = "door", type = "door", x = 700, y = 420 } + }, + name = "start", + tracks = {} + }, + { + entities = { + { id = "ground", type = "ground", x = 360, y = 440 }, + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "coin", type = "coin", x = 400, y = 392 } + }, + name = "cave", + tracks = {} + } + }, + rules = { + { + act = { + { "run", direction = -1, entity = "hero" } + }, + note = "Run left", + on = "frame", + when = { + { "keyHeld", key = "LEFT" } + } + }, + { + act = { + { "run", direction = 1, entity = "hero" } + }, + note = "Run right", + on = "frame", + when = { + { "keyHeld", key = "RIGHT" } + } + }, + { + act = { + { "jump", entity = "hero" }, + { "playSound", file = "Forge/kit/jump.wav" } + }, + note = "Jump, with ground underfoot", + on = "frame", + when = { + { "onGround", entity = "hero" }, + { "keyPressed", key = "SPACE" } + } + }, + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + a = "coin", + act = { + { "addScore", amount = 10 }, + { "destroy", entity = "self" }, + { "playSound", file = "Forge/kit/coin.wav" } + }, + b = "hero", + note = "Take a coin", + on = "enter", + when = {} + }, + { + a = "spike", + act = { + { "moveTo", entity = "hero", x = 200, y = 380 }, + { "playSound", file = "Forge/kit/hit.wav" } + }, + b = "hero", + note = "Spikes hurt", + on = "enter", + when = {} + }, + { + a = "door", + act = { + { "goTo", entity = "hero", room = "cave", x = 60, y = 380 }, + { "addVar", amount = -1, name = "lives" } + }, + b = "hero", + note = "Through the door", + on = "enter", + when = {} + }, + { + act = { + { "playMusic", file = "Forge/kit/loop.ogg" } + }, + note = "Music", + on = "roomStart", + when = {} + }, + { + act = { + { "submitScore", board = "default" }, + { "say", seconds = 2, text = "\"GAME OVER\"" }, + { "gameOver" } + }, + note = "Out of lives", + on = "frame", + when = { + { "test", expr = "lives <= 0" }, + { "once", scope = "game", tag = "over" } + } + }, + { + act = { + { "credit" } + }, + note = "A coin", + on = "pressed", + switch = "SWITCH_COIN1", + when = {} + }, + { + act = { + { "addVar", amount = -1, name = "credits" }, + { "setVar", name = "lives", value = "3" }, + { "restart" } + }, + note = "Continue", + on = "pressed", + switch = "SWITCH_START1", + when = { + { "test", expr = "credits > 0 and lives <= 0" } + } + } + }, + title = "Coin Run", + types = { + coin = { + behaviours = { + { kind = "trigger" }, + { kind = "spin", rate = 180 } + }, + look = { file = "Forge/kit/coin.png", kind = "sprite" }, + vars = {} + }, + door = { + behaviours = { + { kind = "trigger" } + }, + look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, + vars = {} + }, + ground = { + behaviours = { + { kind = "solid" } + }, + look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } + }, + hero = { + behaviours = { + { jump = 620, kind = "platformer", speed = 260 }, + { fps = 10, kind = "frames", states = "idle=1-1, walk=2-5, jump=6-6, fall=7-7" } + }, + look = { file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, + vars = {} + }, + readout = { + look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } + }, + spike = { + behaviours = { + { kind = "trigger" } + }, + look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" }, + vars = {} + } + }, + vars = { credits = 0, lives = 3, score = 0 } +} diff --git a/cmake/Superbuild.cmake b/cmake/Superbuild.cmake index 8a58986c1..820c79c55 100644 --- a/cmake/Superbuild.cmake +++ b/cmake/Superbuild.cmake @@ -461,7 +461,10 @@ ExternalProject_Add(singe # rendered by the engine's own build into .builddir, goes in beside the scripts: Forge copies it # out to its data directory on its first run. if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")) - file(GLOB forgeSources ${CMAKE_SOURCE_DIR}/assets/Forge/*) + # Everything under assets/Forge -- the scripts, the kit, the tutorials -- and re-globbed on + # every build, so a file added or changed anywhere in it repacks Forge.game without anyone + # having to remember. + file(GLOB_RECURSE forgeSources CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/assets/Forge/*) set(forgePack ${CMAKE_SOURCE_DIR}/.builddir/Forge.game) set(forgeManual ${CMAKE_SOURCE_DIR}/.builddir/Forge.pdf) set(forgeStage ${SB_PREFIX}/forgepack) diff --git a/docs/Forge.adoc b/docs/Forge.adoc index cc685cd23..25687cead 100644 --- a/docs/Forge.adoc +++ b/docs/Forge.adoc @@ -10,6 +10,7 @@ Scott Duensing :source-highlighter: rouge :icons: font :experimental: +:imagesdir: images [preface] == About Forge @@ -22,9 +23,1054 @@ description format, the vocabulary, the compiler, and the editor. The engine calls the generated code makes (`playerNew`, `collidePointRect`, `onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe Manual. +== Tutorials + +Ten sittings, each ending with a game that plays, each needing only the one +before it. They use the *kit* -- pictures, sounds, a clip, and a model -- +that travels inside `Forge.game` and is named from a game as +`Forge/kit/hero.png`; `Forge/kit/README.txt` lists it. Every tutorial has a +scene in `testScripts/` that presses the same keys the text names and checks +the result against the description the tutorial ends with, which ships in +`Forge/tutorials/` and appears in the chooser, so a reader who loses the +thread can open the finished game and compare. The pictures come from those +scenes too. + +=== Tutorial 1: Ten minutes + +image::tutorials/01-playing.png[The starter game, playing, 600] + +**What you will make.** Nothing new -- the game Forge starts you with, played, +changed, saved, and played again. Ten minutes, and you know where everything +is. + +**What you need.** Forge, started from the Singe menu. No files. + +**Steps.** + +. Start Forge. It opens on the *chooser*: a list of the descriptions in + its data directory (none yet), the samples and tutorials that travel inside + `Forge.game`, and *New game* at the bottom. The line under the list says + where `Forge.pdf` -- this book -- was put, the first time Forge ran. ++ +image::tutorials/01-chooser.png[The chooser, 600] + +. Press kbd:[Down] until *New game* is lit, then kbd:[Enter]. Forge writes a + starter description into its data directory and opens it. On the left is + the *panel*; everything to its right is the *canvas*, which is the game's + own screen at the game's own coordinates. A hero stands on a strip of + ground, and a readout sits in the corner. ++ +image::tutorials/01-entities.png[The entities panel, 600] + +. The panel is on *Entities* -- the things placed in this room, each named + `id (type)`. Under the list are the fields of whatever is selected; with + nothing selected, the room's own. The last line of that box is Forge's + *message*: it says what just happened, and while you type, what you are + typing. + +. Press kbd:[Tab]. The panel turns to *Types*: what an entity can be. The + starter has three -- `ground`, `hero`, `readout` -- each with a small + picture of its look. Below are the lit type's fields: its look, the + look's size and colour, its vars, and its behaviours. ++ +image::tutorials/01-types.png[The types panel, 600] + +. Press kbd:[Tab] again for *Rules*, the sheet that says what happens. Each + rule is a line; the lit one is opened to show what it is *on*, *when* it + applies, and what it does *then*. Two more kbd:[Tab]s pass *Tracks* and + *Dialogues*, empty for now, and come back round to *Entities*. ++ +image::tutorials/01-rules.png[The rules panel, 600] + +. Back on *Entities*, press the hero on the canvas, or its row in the list. + It gets a yellow frame, and the box under the list shows its fields: + `id`, `type`, `x`, `y`, and the rest. Press kbd:[Enter]. The message + says *type a value for id*, and the id shows selected in its row. Press + kbd:[Enter] again until it is *x* that is selected -- or click the `x` + row -- type `200`, and press kbd:[Enter]; the hero moves as you type. + Then kbd:[Esc] to leave the form. A field passed over with nothing typed + keeps its value, and the arrows put a caret into a value to change part + of it. + +. kbd:[Tab] to *Types*, kbd:[Down] to `hero` (or click it), and + kbd:[Enter] through the form the same way: `look.r` to `90`, `look.b` to `240` -- the hero turns + blue as you type -- and `platformer.speed` to `260`. kbd:[Esc] when the + last is in. ++ +image::tutorials/01-recoloured.png[The hero recoloured, 600] + +. Press kbd:[S]. The star after the title goes: the description is saved, + as a file you could open in a text editor. + +. Press kbd:[P]. The game is built and played: kbd:[Left] and kbd:[Right] + run, kbd:[Space] jumps, and it is a little faster than it was. kbd:[Esc] + ends the game, and Forge comes back on the same description. + +**What happened.** A game in Forge is a *description*: layers, types, rooms +of entities, and rules. The editor only edits that description, and +kbd:[P] compiles it into an ordinary Singe game and runs it. Nothing you saw +is special to the editor: the panels are the description's parts, the canvas +is the game's screen, and the file kbd:[S] wrote is the whole game. The +types and the rest are explained in +<<_describing_a_game_instead_of_writing_one>>; the keys are all in +<<_keys>>. + +**Try this.** + +* Make the hero jump higher: `platformer.jump` in the types panel. +* Give the ground a colour of its own. +* Drag the hero somewhere else with the mouse, and play again. + +**The description.** What kbd:[S] wrote, in full. `Forge/tutorials/01-tenMinutes.game` +in the chooser is this file. + +[source,lua] +---- +include::../assets/Forge/tutorials/01-tenMinutes.game[] +---- + +=== Tutorial 2: A platformer + +image::tutorials/02-playing.png[Coins, spikes, and a door, 600] + +**What you will make.** The starter game with something to do: coins to +collect, spikes that send the hero back, and a door to a second room. + +**What you need.** Tutorial 1, or its finished description +(`01-tenMinutes` in the chooser). From the kit: `coin.png`, `spike.png`, +`door.png`, `coin.wav`, and `hit.wav`. + +**Steps.** + +. Press kbd:[2] for the *Types* panel. (The digits kbd:[1] to kbd:[5] go + straight to a panel; kbd:[Tab] walks them in turn.) Press kbd:[A]: a + type called `thing` appears, lit, a plain grey box. + +. kbd:[Enter] opens its form on `name`. Type `coin`, kbd:[Enter]. The next + field is `look`: type `sprite`, kbd:[Enter]. The look's own fields + follow; kbd:[Enter] past `look.anchor` and `look.faces` to `look.file`, + which opens a list of the files under the game's directory and in the kit. + Type `coin` to narrow it, and kbd:[Enter] takes `Forge/kit/coin.png`. + kbd:[Enter] on to `behaviours`, type `trigger`, kbd:[Enter], then + kbd:[Esc]. The type's row now shows the coin. ++ +image::tutorials/02-coinType.png[The coin type, 600] + +. Press kbd:[1] for *Entities* and kbd:[A]: a coin of the lit type appears + in the middle of the canvas, selected. Drag it down to the ground. Press + kbd:[D] to duplicate it and drag the copy along; kbd:[D] again for a third, + dragged up where a jump will reach it. ++ +image::tutorials/02-coins.png[Three coins placed, 600] + +. The same way, make a type `spike` from `spike.png` and a type `door` from + `door.png`, each a `trigger`, and each with `look.anchor` set to `feet` so + that it stands on the ground where it is placed rather than being centred + there. Place one of each near the right edge, the door beyond the spikes. ++ +image::tutorials/02-placed.png[Spikes and a door, 600] + +. Press kbd:[3] for *Rules* and kbd:[N]: a new rule, on `frame`. + kbd:[Enter] and type its `note`, `Take a coin`, kbd:[Enter], kbd:[Esc]. + Press kbd:[E] to choose what it is on: type `enter` and kbd:[Enter]. + The rule's fields now include `a` and `b`, the trigger and what entered + it: kbd:[Enter] to `a`, type `coin`; kbd:[Enter] to `b`, type `hero`; + kbd:[Enter], kbd:[Esc]. + +. Press kbd:[T] for an action: type `addScore`, kbd:[Enter]. It appears + under the rule, lit, and kbd:[Enter] opens its `amount`: type `10`, + kbd:[Enter], kbd:[Esc]. kbd:[T] again for `destroy`; its `entity` is + already `self`, which in an `enter` rule is the trigger -- the coin. + kbd:[T] once more for `playSound`; kbd:[Enter] opens the file list, type + `coin.wav`, kbd:[Enter]. ++ +image::tutorials/02-rule.png[The coin rule, 600] + +. Two more rules the same way. `Spikes hurt`, on `enter` with `a` `spike` + and `b` `hero`: a `moveTo` with `entity` `hero`, `x` `200`, and `y` `380`, + and a `playSound` of `hit.wav`. `Through the door`, on `enter` with `a` + `door` and `b` `hero`: a `goTo` with `room` `cave`, `entity` `hero`, `x` + `60`, and `y` `380`. + +. The cave does not exist yet. Press kbd:[1] and then kbd:[N]: a second + room, empty, and the panel shows it (`room 2 of 2`). With nothing + selected -- click the game's name at the top of the panel, or an empty + spot on the canvas -- the form is the game's and then the room's: + kbd:[Enter] to `name`, type `cave`, + kbd:[Enter], kbd:[Esc]. Press kbd:[2], light `ground` with the arrows, + kbd:[1], kbd:[A], and drag it to the bottom; the same for a `readout` in + the corner and a `coin`. kbd:[PgUp] and kbd:[PgDn] move between rooms. ++ +image::tutorials/02-cave.png[The cave, 600] + +. kbd:[S], then kbd:[P]. Run into a coin: it goes, the score climbs, and + the clip plays. Run into the spikes and you are back at the start. Reach + the door and you are in the cave. + +**What happened.** A *type* is what a thing is: a look and a list of +behaviours. An *entity* is one placed in a room. The `trigger` behaviour +gives an instance a volume that reports what enters and leaves it, and the +`enter` *event* is what a rule listens for; its `a` and `b` say which two +types, and inside the rule `self` is the trigger and `other` is what came in. +Rooms are separate screens with their own entities, and `goTo` takes an +entity with it. Events and the rest are in <<_events>>; every behaviour and +action is in <<_the_vocabulary>>. + +**Try this.** + +* Make the spikes cost a point: an `addScore` of `-5` in the spike rule. +* Put a `door` in the cave that leads back, with a second `goTo`. +* Rename an entity in its form: the rules that name it change with it. + +**The description.** The rules tutorial 1 left are unchanged; what is new +is the three types, their entities, the three rules, and the second room. + +[source,lua] +---- +include::../assets/Forge/tutorials/02-platformer.game[] +---- + +=== Tutorial 3: Sprites and sound + +image::tutorials/03-playing.png[The hero from the sheet, 600] + +**What you will make.** The same game, with a hero drawn from a sprite +sheet who faces the way he runs, coins that spin, a jump that sounds, and +music. + +**What you need.** Tutorial 2. From the kit: `hero.png`, `jump.wav`, and +`loop.ogg`. + +**Steps.** + +. kbd:[2] for *Types*, and light `hero` with the arrows. kbd:[Enter] to + `look`, type `sprite`, kbd:[Enter]; on to `look.file`, and take + `Forge/kit/hero.png` from the list; on to `look.frames`, type `8`. The + sheet is eight columns: standing, four of walking, a jump, a fall, and a + hit, all facing right. The hero on the canvas is now the first of them. + +. kbd:[Enter] on to `behaviours`. It says `platformer`; type + `platformer, frames` -- a comma list, and a behaviour already there keeps + its values. Two new fields follow the platformer's: `frames.fps`, type + `10`; and `frames.states`, type `idle=1-1, walk=2-5, jump=6-6, fall=7-7` + -- each state's run of frames. kbd:[Enter], kbd:[Esc]. ++ +image::tutorials/03-heroType.png[The hero type, 600] + +. Light `coin`. Its `behaviours` become `trigger, spin`, and `spin.rate` + `180` -- degrees a second. ++ +image::tutorials/03-coinSpin.png[Coins that spin, 600] + +. kbd:[3] for *Rules*. kbd:[Down] to rule 3, `Jump, with ground + underfoot`, and once more onto its first part, `keyHeld`. Press + kbd:[Delete]: the part goes. kbd:[C] for a condition: type + `keyPressed`, kbd:[Enter]; kbd:[Enter] on its `key`, type `SPACE`, + kbd:[Enter], kbd:[Esc]. A press rather than a hold, so what comes next + happens once. kbd:[T], `playSound`, and `jump.wav` from the list. ++ +image::tutorials/03-jumpRule.png[The jump rule, 600] + +. kbd:[N] for a new rule, `note` `Music`. kbd:[E], and choose + `roomStart`: it fires as a room is entered. kbd:[T], `playMusic`, and + `loop.ogg`. ++ +image::tutorials/03-music.png[The music rule, 600] + +. kbd:[S], kbd:[P]. The hero walks, and turns to face the way he runs + though the sheet only has him facing right; he jumps and falls on the + frames for it; the coins turn; and the music loops. + +**What happened.** A `sprite` look with `frames` is a sheet of that many +columns, and the `frames` behaviour picks a column by the instance's *state* +-- a var every instance has, which the `platformer` sets to `idle`, `walk`, +`jump`, or `fall` as it goes, and a rule can set to anything else with +`setState`. The platformer also sets the var `facing`, and a sprite look is +mirrored while it faces away from its art -- the look's `faces` says which +way that is, right unless told. `spin` turns the var `angle`, and a sprite +turns with it. Sound is an action, `playSound`, with a volume of its own if +you want one, and music is another; the `sound` behaviour plays a clip per +event instead, which suits a type that is hit often. Sheets, facing, and +states are in <<_instances_vars_and_states>> and +<<_the_long_tail_sprite_sheets_tiles_midi_water_soft_bodies>>. + +**Try this.** + +* Show the hit frame: in the spike rule, a `setState` of `hit` on the hero, + and a `wait` of `0.5` before the `moveTo`. Actions that wait run as a + sequence. +* Make the coins face the other way: `look.faces` `left` on the coin does + nothing, since a coin has no facing, but on the hero it mirrors everything. +* Turn the door on the canvas with its yellow handle, or type its `rz`. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/03-sprites.game[] +---- + +=== Tutorial 4: A shoot-em-up + +image::tutorials/04-playing.png[Rocks, 600] + +**What you will make.** A game from nothing: a ship on the arrow keys that +fires, rocks that fall from spawners and break up, lives, and a game over. + +**What you need.** Nothing but the kit: `ship.png`, `shot.png`, +`rock.png`, `shot.wav`, and `hit.wav`. + +**Steps.** + +. Open *New game* and clear the starter. kbd:[1], light `hero`, + kbd:[Delete]; the same for `ground`. kbd:[2], light `hero`, kbd:[Delete], + and `ground` -- a type can only go once nothing is placed of it. kbd:[3], + and kbd:[Delete] three times: the running and jumping rules go, leaving + the readout's. + +. kbd:[1], and with nothing selected the form is the game's own, then the + room's. kbd:[Enter] on `title`, type `Rocks`; on `vars`, type + `score=0, lives=3`; on `layers`, type `overlay`. The starter's `world2d` + layer goes with its gravity: nothing falls unless a behaviour says so. ++ +image::tutorials/04-cleared.png[The game's form, 600] + +. kbd:[2], kbd:[A], and the ship: `name` `ship`, `look` `sprite`, + `look.file` `ship.png`, `vars` `health=1`, and `behaviours` + `keys, mover, shooter, health, sound`. Then each behaviour's fields: + `keys.left` `LEFT`, `keys.right` `RIGHT`, `keys.up` `UP`, `keys.down` + `DOWN`, `keys.fire` `SPACE`; `mover.speed` `260` and `mover.clamp` `true`; + `shooter.spawns` `shot`, `shooter.rate` `0.15`, `shooter.offsetY` `-14`; + `health.max` `1` and `health.keep` `true`; `sound.hit` `hit.wav`. A file + field passed with kbd:[Enter] keeps what it has, `(none)` included. ++ +image::tutorials/04-ship.png[The ship type, 600] + +. Three more types. `shot`: a sprite of `shot.png`, behaviours + `projectile, sound`, `projectile.vy` `-520`, `projectile.life` `3`, and + `sound.spawn` `shot.wav`. `rock`: `rock.png`, vars `health=2`, + behaviours `drift, health, sound`, `drift.vy` `90`, `health.max` `2`, + `sound.death` `hit.wav`. `hive`: a look of `none` and a `spawner` with + `spawner.spawns` `rock`, `spawner.every` `0.6`, `spawner.max` `6`, and + `spawner.total` `12`. ++ +image::tutorials/04-types.png[The types, 600] + +. Place them: a `ship` at the bottom, and two `hive`s just above the top + edge, one over the ship. A `none` look is drawn on the canvas as an empty + box, so it can still be picked up. ++ +image::tutorials/04-placed.png[Placed, 600] + +. kbd:[3], and the rules. `A shot lands on a rock`, on `collision` with `a` + `shot` and `b` `rock`: a `destroy` (of `self`, the shot), a `damage` with + `entity` `other` and `amount` `1`, and an `addScore` of `10`. `A rock + dies`, on `death` with `type` `rock`: an `emit` with `count` `24` and a + colour of `255`, `120`, `60`, and an `addScore` of `50`. ++ +image::tutorials/04-hitRule.png[The hit rules, 600] + +. `A rock rams the ship`, on `collision` with `a` `rock` and `b` `ship`: + `destroy`, and `damage` of `other` by `1`. `The ship is lost`, on `death` + with `type` `ship`: `addVar` of `lives` by `-1`, a `flash` of `255`, + `60`, `60` for `0.3` seconds, a `setVar` of `health` to `1`, and a `moveTo` + back to `360`, `420` -- the ship keeps itself through its death, since + `health.keep` is set. `Out of lives`, on `frame`, when a `test` of + `lives <= 0`: `gameOver`. `A rock slips past the bottom`, on `frame` + with `each` `rock`, when a `test` of `self.y > 500`: `destroy`. And the + readout's `text` becomes `"score " .. score .. " lives " .. lives`. ++ +image::tutorials/04-rules.png[The rules, 600] + +. kbd:[S], kbd:[P]. Fly, fire, and the rocks break up in sparks. Let three + through onto the ship and the game is over; kbd:[Esc] brings Forge back. + +**What happened.** Without a `world2d` layer there is no physics, and the +behaviours move things themselves: `keys` reads a player's keys into the +vars `dx`, `dy`, and `fire`, `mover` moves by them, `shooter` spawns a type +while `fire` is set, `drift` moves steadily, and `projectile` flies until +it leaves the picture. `collision` still fires, from the boxes overlapping. +`health` gives an instance the var `health` and raises `death` at zero; +`spawner` makes a type every so often up to a limit. A rule with `each` +runs once per instance of a type, with that instance as `self`, and a +`test` is any expression over the vars. Expressions are in +<<_expressions>>. + +**Try this.** + +* Give the rocks a `spin` behaviour. +* A second `shooter` on the ship, `offsetX` `-10` and `10`, for twin guns. +* A `hud` layer instead of the readout: `layers` `overlay, hud`, and a + `hud.document` of your own RmlUi page with elements named for the vars in + `hud.bind`. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/04-shmup.game[] +---- + +=== Tutorial 5: A quick-time event + +image::tutorials/05-playing.png[A window answered, 600] + +**What you will make.** A game over video: the kit's clip plays, and at +two moments in it a prompt wants a key. In time scores; too late costs a +life. The clip loops, and the game ends when the lives are gone. + +**What you need.** The kit's `clip.mkv`: twelve seconds in which a door +opens at three, a target rises at six, and a hand reaches in at nine, with a +clock along the bottom. At 24 frames a second, a moment's frame is its +second times 24. + +**Steps.** + +. Open *New game* and clear it as in tutorial 4: the hero and ground + entities, then their types, then the three running and jumping rules. + +. kbd:[1], nothing selected, and the game's form: `title` `Moments`, + `vars` `score=0, lives=3`, and `layers` `disc, overlay`. A `disc` is the + video a game is played over, and `disc.file` is now a field: kbd:[Enter] + opens the list, take `Forge/kit/clip.mkv`. The canvas shows the clip's + first frame. ++ +image::tutorials/05-disc.png[The disc layer, 600] + +. Two text types. kbd:[2], kbd:[A], `name` `prompt`, `look` `text`, and + a colour of `255`, `220`, `80`; kbd:[1], kbd:[A], and drag it near the + top. Then `verdict`, `text`, `160`, `255`, `180`, placed under it. ++ +image::tutorials/05-prompts.png[The prompts, 600] + +. kbd:[3], and three rules for the first window, frames `72` to `120`, + when the door opens. `Window one is open`, on `frame`, when + `discBetween` with `from` `72` and `to` `120`: a `setText` of `prompt` to + `"PRESS!"`. `Window one answered in time`, on `frame`, when + `discBetween` `72` to `120`, `keyPressed` with `key` `SPACE`, and `once` + with `tag` `one`: a `setVar` of `hitone` to `true`, an `addScore` of + `250`, and a `setText` of `verdict` to `"HIT"`. `Window one closed + unanswered`, on `frameReached` with `frame` `120`, when a `test` of + `not hitone`: an `addVar` of `lives` by `-1`, `verdict` to `"MISS"`, and + `prompt` to `""`. ++ +image::tutorials/05-window.png[The first window, 600] + +. The same three for the second window, `150` to `200`, where the target + rises, wanting `UP` and setting `hittwo`. Then `The clip runs out: + round again`, on `frameReached` at `280`: `hitone` and `hittwo` back to + `false`, `verdict` to `""`, and a `discTo` with `frame` `1`. `Out of + lives`, on `frame`, when `lives <= 0`: `gameOver`. The readout's `text` + becomes `"score " .. score .. " lives " .. lives`. ++ +image::tutorials/05-rules.png[The rules, 600] + +. kbd:[S], kbd:[P]. The door opens and the prompt appears; kbd:[Space] in + time is a hit. Miss the target's window and a life goes. + +**What happened.** A `disc` layer plays a video underneath everything, and +the rules read its *frame*: `discBetween` is true while the disc is inside +a window, `frameReached` fires as it passes a frame, and `discTo` sends it +elsewhere -- the whole of a Dragon's Lair is windows and jumps. `once` +keeps a rule from firing every frame the window is open, and `keyPressed` +is the frame a key goes down, where `keyHeld` is every frame it is down. +`setVar` and `test` on a var of your own are how a rule remembers what +another did. The `branching` behaviour does all of this in one track for a +game of many branches; `Forge/fmv.game` in the chooser is one. In a +released game, `games.dat` names the video, as it does for any Singe game. + +**Try this.** + +* A third window for the hand at `216` to `264`. +* Show the frame: the readout's text with `.. " frame " .. frame`. +* Make a miss send the disc somewhere: a `discTo` in the closed rule. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/05-qte.game[] +---- + +=== Tutorial 6: A light gun game + +image::tutorials/06-playing.png[Draw, 600] + +**What you will make.** Mad Dog McCree in miniature: the clip plays, a +target rises, and the player shoots it with the mouse or a light gun. The +hand that reaches in is not to be shot. Ammo runs out and is reloaded by a +shot off the picture. + +**What you need.** The kit's `clip.mkv`, `hit.wav`, and `shot.wav`. + +**Steps.** + +. Open *New game*, clear it as before, and give the game's form `title` + `Draw`, `vars` `score=0, misses=0`, `layers` `disc, overlay`, and + `disc.file` `Forge/kit/clip.mkv`. + +. kbd:[2], and three types with a look of `none`. `gun`: `vars` `ammo=6`, + `behaviours` `gun`, `gun.player` `1`, `gun.ammo` `6`, `gun.reload` + `offscreen`. `target`: `vars` `health=1`, `behaviours` `hitbox, health`, + `hitbox.track` `target`, `health.max` `1`. `hand`: `behaviours` + `hitbox`, `hitbox.track` `hand`. Place one of each anywhere; a hitbox's + place comes from its track, not from where it stands. ++ +image::tutorials/06-types.png[The types, 600] + +. kbd:[4] for *Tracks*, and kbd:[N]: a new track, keyed by frame. + kbd:[Enter], `name` `target`, kbd:[Enter], kbd:[Esc]. Along the bottom of + the canvas is the *timeline* with its cursor: kbd:[.] and kbd:[,] step it + a frame, kbd:[\]] and kbd:[\[] leap ten, and the canvas shows the clip at + that frame. Take it to `150`, where the target has risen, and press + kbd:[K]: a key at that frame, with a box in the middle of the canvas. + kbd:[Enter] through the key's form: `x` `405`, `y` `165`, `w` `90`, `h` + `90` -- or drag the box over the target and pull its corner. ++ +image::tutorials/06-firstKey.png[The first key, 600] + +. Another key at `210`, the same box, so the target stays hittable while it + is up; between keys the box slides from one to the next, and outside them + there is none. kbd:[N] for a second track, `hand`, with keys at `220` and + `260` of `480`, `157`, `240`, `105`. ++ +image::tutorials/06-tracks.png[Two tracks, 600] + +. kbd:[3], and the rules. `The target is hit`, on `hit` with `type` + `target`: `addScore` `100`, `damage` of `self` by `1`, and `playSound` + `hit.wav`. `The hand is hit`, on `hit` with `type` `hand`: `addScore` + `-200` and a red `flash`. `A shot at nothing`, on `miss`, when a `test` of + `not event.offscreen`: `addVar` `misses` by `1`, `playSound` `shot.wav`. + `Draw!`, on `frameReached` at `150`: a `say` of `"DRAW!"` for `1` second. + The readout's `text`: `"score " .. score .. " ammo " .. gun.ammo .. " misses " .. misses`. ++ +image::tutorials/06-rules.png[The rules, 600] + +. kbd:[S], kbd:[P]. Shoot the target as it rises; shoot the hand and pay + for it; when the ammo is gone, shoot off the edge of the picture to + reload. + +**What happened.** A `gun` behaviour turns a player's pointer -- the mouse, +or a light gun, which is a mouse to the engine -- into shots: the trigger +raises `hit` on the instance under it, with `self` the thing hit and `other` +the gun, or `miss` with `event.offscreen` saying whether the shot left the +picture. A `hitbox` behaviour takes its shape from a *track* of boxes keyed +by disc frame, which is how a thing in a video can be shot only while it is +there: the boxes are where the thing is in the picture, and the editor +shows the frame under them. Tracks are in <<_tracks>>. What a cabinet +with a light gun needs -- calibration, two guns, the recoil -- is in +<<_arcade_plumbing_branching_video_lives_credits_the_board>>. + +**Try this.** + +* A second player: `players` `2` in the game's form, a second `gun` with + `gun.player` `2`, and `hit` rules with `player` set. +* Shoot the door as it opens: a third track, and a type with `health.max` + `3` that takes three hits. +* Score by where the shot landed: a `target` behaviour with `zones` for a + 3D model is in tutorial 9; in 2D, two hitbox types on two tracks make a + bull and an outer ring. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/06-gun.game[] +---- + +=== Tutorial 7: A point-and-click adventure + +image::tutorials/07-playing.png[The yard, 600] + +**What you will make.** A room of the Sierra and LucasArts kind: a painted +yard, a hero who walks where you click, things to look at, take, open, and +talk to, a key that goes in the inventory, a guard with a conversation, and +a door to a second painted room. + +**What you need.** The kit's `yard.png`, `hall.png`, `hero.png`, `key.png`, +`door.png`, and `door.wav`. + +**Steps.** + +. Open *New game*, clear it, and fill the game's form: `title` + `The Yard`, `vars` `score=0, allowed=false`, `verbs` + `walk, look, take, use, open, talk`, and `layers` `overlay`. Then the + room's own fields, further down the same form: `name` `yard`, + `depthSort` `true`, and `scaleBy` `300:0.6, 460:1.0` -- things standing + at the back of the yard are drawn at six tenths, at the front full size, + and between in proportion. + +. kbd:[2], kbd:[A]: a type `backdrop`, a `sprite` of `yard.png`. kbd:[1], + kbd:[A], and it fills the canvas. Now the floor the hero may walk on. + Press kbd:[V]: a walk area is being drawn, the panel steps aside, and + every press on the canvas is a corner. Press the four corners of the + yard's floor -- left and right where the wall meets it, then the near + corners -- and kbd:[V] again to close it. A green outline shows it, and + the panel comes back. ++ +image::tutorials/07-walkArea.png[The walk area, 600] + +. The hero: a `sprite` of `hero.png` with `look.frames` `8` and + `look.anchor` `feet`, `behaviours` `walker, frames`, `walker.speed` `160`, + `frames.fps` `10`, and `frames.states` `idle=1-1, walk=2-5`. Place him at + the front left. A walker's state is `walk` or `idle` as it goes, and the + frames follow. + +. Three hotspots. `door`: `door.png`, `look.anchor` `feet`, `behaviours` + `hotspot`, `hotspot.name` `door`, and a place to walk to first, + `hotspot.walkX` `590` and `hotspot.walkY` `330`; place it in the doorway. + `key`: `key.png`, a hotspot named `key` with a walk point of `200`, + `400`, placed on the floor. `guard`: `hero.png` again, `frames` `8`, + `look.faces` `left` so he looks toward the hero, a hotspot `guard` at + `480`, `360`, placed by the door. ++ +image::tutorials/07-hotspots.png[The hotspots, 600] + +. kbd:[5] for *Dialogues*, and kbd:[A]: a dialogue called `talk` with one + node, `start`. kbd:[Enter], `name` `guard`. kbd:[Down] onto the node: + `who` `Guard`, `text` `Nobody passes.` kbd:[A] on a node adds a choice: + `text` `Why not?`, `next` `why`. kbd:[A] again: `Fine.`, with no `next`, + which ends the talk. kbd:[Up] to the dialogue's own row and kbd:[A] for + a second node: `name` `why`, `who` `Guard`, `text` + `The door is locked, and I have no key.` Its choices: `I have a key.` + with `when` `has("key")` and `next` `ok`, and kbd:[T] on that choice for + an action, `setVar` of `allowed` to `true` -- kbd:[Delete] on its `entity` + empties it, so the var is the game's; then `I see.` A third node `ok`: + `Then go ahead.` ++ +image::tutorials/07-dialogue.png[The dialogue, 600] + +. kbd:[3], and the rules. `A click on the floor walks there`, on `pressed` + with `switch` `SWITCH_BUTTON3` and `interrupt` `true`: a `walkToPointer` + of `hero` for `player` `1`. `Look at anything`, on `verb` with `verb` + `look`: a `say` of `"It is a " .. event.target .. "."` for `1` second. + `Take the key`, on `verb` `take` with `target` `key`: `walkToHotspot` of + `hero` (its `target` is `self`, the key), `give` `key`, `addScore` `1`, + `destroy`. `Talk to the guard`, on `verb` `talk` with `target` `guard`: + `walkToHotspot`, `face` of `hero` toward `self`, and `talk` with + `dialogue` `guard`. ++ +image::tutorials/07-verbRules.png[The verb rules, 600] + +. `The door, while the guard objects`, on `verb` `open` with `target` + `door`, when `not allowed`: `say` `"The guard shakes his head."`. + `The door opens`, the same event with `controls` `false`, when `allowed`: + `walkToHotspot`, `playSound` `door.wav`, a `fade` of `0.5` with `out` + `true`, a `goTo` of `hero` to `room` `hall` at `120`, `420`, a `fade` back + with `out` `false`, and `addScore` `1`. `Anything else`, on `verb` with + nothing filled in: `say` `"That does not work."`. Then the verb keys: + on `pressed` with `key` `L`, a `setVerb` of `look`; `T` for `take`, `O` + for `open`, `K` for `talk`. The readout's `text`: + `(sentence or "") .. " score " .. score`. + +. The hall. kbd:[1], kbd:[N] for a room, `name` `hall`, `depthSort` + `true`. Place a `backdrop` -- and, since the type's picture is the yard, + a second type `hall` from `hall.png` does better -- a walk area along its + floor, and a `readout`. kbd:[PgUp] goes back to the yard. ++ +image::tutorials/07-hall.png[The hall, 600] + +. kbd:[S], kbd:[P]. Click the floor and the hero walks; the sentence line + says what the pointer is over. kbd:[T] then a click on the key takes it. + kbd:[K] and the guard: with the key, the second choice opens, and the + guard relents. kbd:[O] and the door, and the yard fades to the hall. + +**What happened.** A room's *walk areas* become a navigation mesh, and a +`walker` finds its way across them; `depthSort` draws what is lower on the +screen in front, and `scaleBy` makes what is further up smaller, so a flat +picture reads as a floor. A `hotspot` is anything a *verb* can be used on: +the game's `verbs` are the sentence's first word, the pointer's hotspot its +second, and a click raises `verb` with both, the most specific rule winning +-- `open` on `door` beats `open` on anything, and a rule with no `verb` at +all is the last resort. `give`, `take`, and `has` are the inventory; +`talk` runs a *dialogue*, a tree of what is said and what may be answered, +each choice with a `when` and actions of its own. Actions that take time +-- `walkToHotspot`, `say`, `fade`, `talk` -- make a rule a *sequence* that +runs to its end while the rest of the game goes on, unless `controls` is +off. All of it is in +<<_adventures_rooms_walking_hotspots_verbs_and_talk>>. + +**Try this.** + +* A `use` rule: `verb` `use` with `item` `key` and `target` `door`, for + when the player tries the key on the lock. +* A parser: `layers` `overlay, parser`, and rules on `said` with a `verb` + and a `noun` -- the Sierra way, typed. +* A `saveGame` on a key, and a `loadGame` on another. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/07-adventure.game[] +---- + +=== Tutorial 8: Into 3D + +image::tutorials/08-playing.png[The fox in the field, 600] + +**What you will make.** The platformer again, in a scene: a fox on the +engine's character controller, a floor and a ledge, a prize on the ledge, and +a camera that follows. + +**What you need.** The kit's `fox.glb` and `coin.wav`. + +**Steps.** + +. Open *New game*, clear it, and in the game's form set `title` `Field` + and `layers` `scene3d, overlay`. The canvas becomes the *viewport*: the + scene itself, built from the types' looks, under an editor camera. + `scene3d.fov` `55` narrows the view a little. ++ +image::tutorials/08-scene.png[A scene, 600] + +. kbd:[2], and three `mesh` types -- solid shapes with a colour. `floor`: + `look` `mesh`, `look.shape` `box`, `look.w` `30`, `look.h` `0.4`, + `look.d` `12`, a colour of `90`, `110`, `90`, and `behaviours` `solid`. + `ledge`: a box of `4`, `0.4`, `4` in `110`, `90`, `70`, also `solid`. + `prize`: `look.shape` `sphere`, `look.w` `0.6`, in `255`, `200`, `60`, + with `behaviours` `trigger`. Sizes are world units; a unit is about a + metre. ++ +image::tutorials/08-meshes.png[The meshes, 600] + +. The hero: `look` `model`, `look.file` `fox.glb`, `look.scale` `0.008` + (the fox is drawn in centimetres), and `look.w` `0.6`, `look.h` `0.9`, + `look.d` `0.6` for how big it counts as. `behaviours` + `keys, character, animator`: `keys.left` `LEFT` and so on for the four + arrows; `character.speed` `4`, `character.jump` `6.5`, + `character.radius` `0.3`, `character.height` `0.9`; `animator.idle` + `Survey` and `animator.walk` `Walk`, two of the fox's own clips. And a + `camera`: a look of `none`, `behaviours` `camera`, `camera.mode` + `follow`, `camera.target` `hero`, an offset of `camera.x` `0`, `camera.y` + `3`, `camera.z` `7`, `camera.lookY` `0.6`, and `camera.lag` `0.2`. ++ +image::tutorials/08-hero.png[The hero and the camera, 600] + +. Place them. kbd:[1], kbd:[A] puts an entity on the floor under the middle + of the viewport; its `x`, `y`, and `z` are typed, in units, to a tenth. + The `floor` at `0`, `-0.2`, `0`; the `ledge` at `6`, `1.2`, `0`; the + `prize` at `6`, `2`, `0`; the `hero` at `-4`, `0.5`, `0`; the `camera` + at `-4`, `3`, `7`. kbd:[J] and kbd:[L] orbit the editor camera round + what is selected, kbd:[Y] and kbd:[H] tilt it, kbd:[I] and kbd:[K] close + in and back off. An entity is picked by a press where it stands and + dragged across the floor; the selected one carries the *gizmo*, three + arms along the axes -- drag an arm's handle to move along that axis alone + -- and a yellow fourth that turns it. ++ +image::tutorials/08-viewport.png[The viewport, 600] + +. kbd:[3], and the rules. `Walking is a state the animator plays`, on + `frame` with `each` `hero`, when a `test` of + `self.dx != 0 or self.dy != 0`: a `setState` of `self` to `walk`. + `Standing still`, the same with `self.dx == 0 and self.dy == 0` and + `idle`. `Jump, with ground underfoot`, on `frame`, when `keyPressed` + `SPACE` and `onGround` of `hero`: `jump` `hero`. `The prize is taken`, + on `enter` with `a` `prize` and `b` `hero`: `destroy`, `addScore` `100`, + `playSound` `coin.wav`. `Fallen off the world`, on `frame`, when + `hero.y < -5`: a `moveTo` of `hero` back to `-4`, `0.5`, `0`. ++ +image::tutorials/08-rules.png[The rules, 600] + +. kbd:[S], kbd:[P]. The arrows walk the fox relative to the camera, which + follows; kbd:[Space] jumps it onto the ledge, and the prize goes. + +**What happened.** A `scene3d` layer makes the room a scene: positions are +world units in three axes, physics is Jolt's, and the picture is drawn from +whichever instance has the `camera` behaviour. `mesh` looks are shapes, +`model` looks are glTF files with their own animation clips, which an +`animator` plays by state. `character` is the engine's character +controller, moved by the vars `dx` and `dy` that `keys` sets, relative to +the camera; `trigger` and `enter` work as they did on the flat. The +viewport is the same scene under an editor camera, which is why what is +placed is what is seen. Scenes are in <<_games_in_the_scene>> and the +viewport in <<_the_3d_viewport>>. + +**Try this.** + +* A `light` type -- `look` `light`, `look.type` `point`, a colour and an + `intensity` -- placed above the ledge. +* `camera.mode` `orbit`, or `first` for the fox's own eyes. +* A `sky`: `scene3d.sky` with a picture of your own. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/08-scene.game[] +---- + +=== Tutorial 9: A rail shooter + +image::tutorials/09-playing.png[The dead yard, 600] + +**What you will make.** House of the Dead in a yard: the camera rides a +rail and stops; at each stop zombies come out and walk at you; the gun on +the pointer drops them, ragdolls and all; when the stop is clear the rail +moves on. The yard is the painting from tutorial 7, standing in the scene, +with its pillar as an *occluder* -- a box that is not seen but hides what +walks behind it, so a zombie passes behind a pillar that is only paint. + +**What you need.** The kit's `fox.glb`, `yard.png`, and `hit.wav`. + +**Steps.** + +. Open *New game*, clear it, and in the game's form: `title` + `The Dead Yard`, `vars` `score=0, health=3`, `layers` `scene3d, overlay`, + `scene3d.fov` `60`. + +. The room's meshes. `floor`: a `mesh` box of `40`, `0.2`, `60` in `70`, + `75`, `90`, `solid`. `painting`: a box of `24`, `16`, `0.2` in white + (`255`, `255`, `255`) with `look.texture` `yard.png` and `look.unlit` + `true`, so the picture is shown as painted and not as lit. `pillar`: a + box of `1.2`, `8`, `1.2` with `look.occluder` `true` and no colour at + all. ++ +image::tutorials/09-room.png[The room's types, 600] + +. The rest. `camera`: a look of `none`, `behaviours` `camera`, + `camera.mode` `rail`, `camera.track` `rail`. `gun`: as in tutorial 6. + `zombie`: `look` `model`, `look.file` `fox.glb`, `look.scale` `0.01`, a + size of `0.8`, `0.9`, `1.4`, `vars` `health=2`, and `behaviours` + `health, seek, animator, target, timer`: `health.max` `2`, + `health.ragdoll` `true`, `health.linger` `2`; `seek.target` `camera`, + `seek.speed` `1.6`, `seek.stopAt` `2.2`; `animator.idle` `Survey`, + `animator.walk` `Walk`, `animator.attack` `Run`; `timer.name` `bite`, + `timer.after` `1.5`, `timer.start` `false`. ++ +image::tutorials/09-zombie.png[The zombie, 600] + +. Place the `floor` at `0`, `-0.1`, `-20`; the `painting` at `0`, `6`, + `-30`, standing across the far end; the `pillar` at `0`, `4`, `-22`, in + front of the painted one; the `camera` at `0`, `1.6`, `0`; the `gun` + anywhere. ++ +image::tutorials/09-placed.png[Placed, 600] + +. kbd:[4] for *Tracks*, kbd:[N]: in a scene a new track is a rail, keyed + by time. `name` `rail`. kbd:[K] puts a point where the editor camera + stands, looking where it looks; its form takes the rest. Four points: + `at` `0` at `0`, `1.6`, `0` looking at `0`, `1.2`, `-10`; `at` `4` at + `0`, `1.6`, `-6` looking at `0`, `1.2`, `-16` with `stop` `gate`; `at` + `10` at `2`, `1.6`, `-14` looking at `2`, `1.2`, `-24` with `stop` + `pillar`; and `at` `14` at `2`, `1.6`, `-18` looking at `2`, `1.2`, + `-28`. The rail is drawn through the viewport with its stops marked. ++ +image::tutorials/09-rail.png[The rail, 600] + +. kbd:[3], and the rules. `The gate: two come out of the dark`, on + `stopped` with `name` `gate`: two `spawn`s of `zombie`, at `-2`, `0`, + `-14` and `2`, `0`, `-15`. `The pillar: three more, one from behind + it`, on `stopped` `pillar`: spawns at `-1`, `0`, `-23`; `0`, `0`, + `-26`; and `4`, `0`, `-24`. `A zombie walks the moment it is made`, on + `spawn` with `type` `zombie`: `setState` `walk`. `A shot lands`, on + `hit` with `type` `zombie`: `damage` `1`, `addScore` `100`, `playSound` + `hit.wav`. ++ +image::tutorials/09-hitRule.png[The hit rules, 600] + +. `It reaches the camera and rears up`, on `arrived` with `type` `zombie`: + `setState` `attack` and a `timerStart` of `bite` after `1.5`. `The bite + lands unless it was killed in time`, on `timer` with `type` `zombie` and + `name` `bite`, when `inState` `attack`: `addVar` `health` by `-1` (its + `entity` emptied with kbd:[Delete]), a red `flash`, a `shake` of `0.3` + for `0.4`, and the timer started again. `The stop is clear: move on`, on + `frame`, when `waiting` with `entity` `camera` and a `test` of + `count("zombie") == 0`: `pathNext` `camera`. `Out of health`, on + `frame`, when `health <= 0` and `once` with `tag` `over` and `scope` + `game`: a `say` of `"YOU ARE DEAD"` and `gameOver`. `The end of the + rail`, on `railEnd`: `say` `"STAGE CLEAR"`. The readout's `text`: + `"score " .. score .. " health " .. health .. " ammo " .. gun.ammo`. ++ +image::tutorials/09-rules.png[The rules, 600] + +. kbd:[S], kbd:[P]. The camera glides to the gate; two foxes come at you. + Shoot them: two hits each, and they fall limp. The rail moves on to the + pillar, where one comes out from behind it. + +**What happened.** A `camera` in `rail` mode rides a track of points in +time, each with a place and a point to look at, and holds at a point with a +`stop`, raising `stopped` with its name; `waiting` is true while it holds, +`pathNext` sends it on, and `railEnd` fires at the last point. `seek` +walks an instance straight at a target and raises `arrived`; a `timer` +raises `timer` after its seconds, started by `timerStart`. In a scene the +gun casts a ray from the pointer, and what it meets is a `target` -- a +body the size of its look, with `zones` on named bones if a headshot should +count for more. `health.ragdoll` lets a dead model fall on its own bones +and `linger` before it goes. An `occluder` mesh writes depth and no +colour: anything behind it is hidden, and the painting behind it shows +through, which is how a 3D character walks behind a pillar that is only in +the picture -- the way a Grim Fandango room is built. Rails and the gun are +in <<_games_in_the_scene>>; painted rooms in +<<_adventures_rooms_walking_hotspots_verbs_and_talk>>. + +**Try this.** + +* Headshots: `target.zones` on the zombie, a list of boxes on named bones + typed as `name=head, bone=b_Head_05, w=0.5, h=0.5, d=0.5`, and a `hitPart` + condition of `head` in a second hit rule that does `2` damage. +* Fog: `scene3d.fog` `r=12, g=12, b=22, near=25, far=60`. +* A third stop, and a `light` type above it. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/09-rail.game[] +---- + +=== Tutorial 10: Releasing your game + +image::tutorials/10-playing.png[Coin Run, 600] + +**What you will make.** The platformer from tutorial 3 as an arcade game: +lives, a coin slot, a continue, the score panel round the picture, the score +sent to the board -- and a folder with everything it needs, ready to be +packed into a `.game` and listed by the Singe menu on a machine that has +never seen Forge. + +**What you need.** Tutorial 3, or `03-sprites` from the chooser. + +**Steps.** + +. Open it, and in the game's form: `title` `Coin Run`, `vars` + `score=0, lives=3, credits=0`, and `layers` `world2d, bezel`. The + `bezel` is the arcade's score panel round the picture, showing the vars + `score`, `lives`, and `credits` as they change. ++ +image::tutorials/10-bezel.png[The bezel layer, 600] + +. kbd:[3], and onto rule 6, `Spikes hurt`, kbd:[Down] past its parts to the + last, and kbd:[T]: an `addVar` of `lives` by `-1`, its `entity` emptied. + Then `Out of lives`, on `frame`, when `lives <= 0` and `once` with `tag` + `over` and `scope` `game`: a `submitScore` with `board` `default`, a + `say` of `"GAME OVER"` for `2` seconds, and `gameOver`. `A coin`, on + `pressed` with `switch` `SWITCH_COIN1`: `credit`. `Continue`, on + `pressed` with `switch` `SWITCH_START1`, when + `credits > 0 and lives <= 0`: `addVar` `credits` by `-1`, `setVar` + `lives` to `3`, and `restart`. The readout's `text` gains + `.. " lives " .. lives`. ++ +image::tutorials/10-arcadeRules.png[The arcade rules, 600] + +. kbd:[S], then kbd:[X]: the game is *released* into a folder named for + it in Forge's data directory -- `CoinRun` -- and the message says where. + In it: `CoinRun.singe`, the compiled game; `Author.singe`, the runtime + it needs; `CoinRun.game`, the description, so it can be opened again; + `games.dat`, so the menu lists it, naming the script as the menu will see + it, `CoinRun/CoinRun.singe`; and every file the description named, at the + same relative name -- here the kit's hero, coins, and sounds under + `Forge/kit/`. ++ +image::tutorials/10-released.png[Released, 600] + +. Pack it, as any Singe game is packed, from a shell in Forge's data + directory (the chooser said where it is, beside `Forge.pdf`): ++ +[source,text] +---- +Singe --pack CoinRun CoinRun.game +---- ++ +Copy the `.game` beside the other games, and the menu lists *Coin Run* +next time it starts. Nothing of Forge is in it but the runtime it +carries. A packed game left beside the descriptions is not mistaken for +one: the chooser can tell them apart. + +**What happened.** A released game is an ordinary Singe game: a script, +a `games.dat`, and its files. The compiled script finds its own folder and +loads the runtime and every named file from there first, so it runs wherever +it is put, packed or loose. `submitScore` queues the score for the master +service's board for this game, sent when the machine can reach it, and the +menu shows the board. `credit` counts a coin, and the bezel's `credits` +is what a cabinet's coin door shows. Releasing is in +<<_releasing_a_game>>; the board, the bezel, and what a cabinet needs are in +<<_arcade_plumbing_branching_video_lives_credits_the_board>>. + +**Try this.** + +* Open `CoinRun.game` from the release folder in Forge -- drop it in the + chooser's data directory -- change something, and release again. +* Two players: `players` `2`, a second hero on the other keys, and + `score2` and `lives2` for the bezel's other side. +* A `hud` layer instead of the readout, with an RmlUi document of your own. + +**The description.** + +[source,lua] +---- +include::../assets/Forge/tutorials/10-release.game[] +---- + +=== Recipes + +The rest of the catalogue, a paragraph each, with a sample to open from the +chooser. A recipe names the behaviours; the tutorials above have shown how +each is typed. + +*Racing* (`racing.game`). A `scene3d` room, a `vehicle` behaviour on a +mesh -- `type` `car`, `motorcycle`, `tank`, or `boat`, with its `mass`, +`thrust`, `torque`, and wheel sizes -- driven by `keys` through the vars `dy` +(throttle) and `dx` (steering), a `camera` in `follow` mode behind it, and +opponents on `racer`, which drives a vehicle round a track of points on its +own. A `trigger` across the finish line, a `lap` var, and a `timer` for the +clock. + +*Tower defence* (`towerdefence.game`). Creeps on `patrol` along a list of +points to the base, a `spawner` making waves of them, towers with `turret` +-- `targets` the creep type, a `range`, a `rate`, and a `spawns` projectile +or plain `damage` -- placed by `spawnAtPointer` on a click while the gold +var allows, and `patrolEnd` costing a life. + +*Pool and water* (`pool.game`). A `mesh` with the `water` behaviour is a +pool: bodies inside it float or sink by their `buoyancy`, and a `soft` +mesh is a cloth or a balloon. `body` gives anything mass, bounce, and +friction; `push` shoves it; `joint` hangs one body from another by a +hinge, a ball, or a slider. + +*Bowling* (`bowling.game`). Ten `body` pins and a ball pushed along the +lane, a `trigger` behind the pins that counts what falls through, and a +`frame` rule that resets the rack with `moveTo` when the ball is past them. + +*The maze* (`maze.game`). A `grid` look draws a map of tiles from a sheet +-- `columns` wide, `tile` pixels square, the `map` rows of tile numbers -- +and `solid` walls are entities placed on it; `keys` and `mover` with +`clamp` walk the maze, a `timer` runs the clock, and `enter` on the exit +`trigger` ends it. + +*Third person* (`thirdperson.game`). Tutorial 8's fox with an `orbit` +camera: the mouse turns the view, the arrows walk relative to it, and +`camera.eye` raises the viewpoint. `animator` with `attack` and `hit` +clips, `health` on what it fights, and `animationDone` to return to `idle`. + +*Space* (`space.game`). `thrust` pushes a body along its nose by `dy` and +turns it by `dx` -- a ship without lift -- with `projectile`s that `aim` +along their flight and a `spawner` of rocks that `drift`. + +*Rhythm and MIDI* (`rhythm.game`). A game that opened a MIDI port hears +`midi` events with `event.pitch` and `event.velocity`; a `timeBetween` +window round each beat, and `once` per note, scores the hit. A `music` +layer plays the track from the start. + +*The 3D adventure* (`adventure3d.game`). Tutorial 7's rooms in a scene: +`walker` on the floors named in the room's `navFrom`, hotspots with a +`walkX`, `walkY`, `walkZ`, a painted backdrop on an unlit mesh, and +`occluder` boxes where the painting's furniture stands, so the hero walks +behind it. The verbs, the inventory, and the dialogues are exactly the flat +ones. + +*The board and the bezel* (`fmv.game`). `bezel` round any game, `credit` +on the coin switch, `restart` on start with credits, and `submitScore` at +the end; `branching` for a Dragon's Lair of windows, moves, and jumps. + +*Anything else.* The `lua` action runs a line of Singe Lua in a rule, with +`self`, `other`, and `event` in scope, and the engine's whole API with them: +the way out when the vocabulary stops, and it is meant to be used. + + == Describing a Game Instead of Writing One -A game can be written as a *description* -- a table of kinds, rooms, and rules +A game can be written as a *description* -- a table of types, rooms, and rules -- and compiled into an ordinary Singe game. `Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime the result calls. **No part of Forge ships with Singe** -- not the editor, not the compiler, not the runtime -- @@ -45,19 +1091,19 @@ positions are world units), `overlay` (flat drawing), `disc` (the video the game is played over), `hud` (an RmlUi document bound to the game's vars), and `music`. A game lists the ones it wants. -*Kinds* are what things are: a `look` (`box`, `sprite`, `text`, or `none` in +*Types* are what things are: a `look` (`box`, `sprite`, `text`, or `none` in 2D; `model`, `mesh`, `light`, `billboard`, or `text3d` in the scene), any number of `behaviours`, and `vars` -- the state every instance starts with. -Everything placed or spawned is an instance of a kind. +Everything placed or spawned is an instance of a type. -*Rooms* are where things are. A room lists its `entities` -- a kind, an id, a +*Rooms* are where things are. A room lists its `entities` -- a type, an id, a position, and any vars that differ -- and its `tracks`. A room keeps its state when it is left unless it says `reset = true`; vars declared on the game live across rooms. *Rules* are a trigger, conditions, and actions. `on` names the event -- every -frame, a collision between two kinds, a shot landing, a death, a timer, a disc -frame passing -- and `each` scopes the rule to every instance of a kind, with +frame, a collision between two types, a shot landing, a death, a timer, a disc +frame passing -- and `each` scopes the rule to every instance of a type, with `self` bound. All of a rule's conditions must hold for its actions to run; an `any` group inside them is an OR. @@ -68,7 +1114,7 @@ return { title = "One rule", layers = { { kind = "world2d", gravity = 1500 } }, vars = { score = 0 }, - kinds = { + types = { ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, @@ -76,8 +1122,8 @@ return { }, rooms = { { name = "start", - entities = { { kind = "ground", id = "ground", x = 360, y = 440 }, - { kind = "hero", id = "hero", x = 120, y = 380 } } } + entities = { { type = "ground", id = "ground", x = 360, y = 440 }, + { type = "hero", id = "hero", x = 120, y = 380 } } } }, rules = { { note = "Run right", @@ -106,7 +1152,7 @@ sprite, tile, MIDI, water, and soft-body pieces -- and === Instances, vars, and states -A kind's `vars` are what each instance starts with: `vars = { health = 3, +A type's `vars` are what each instance starts with: `vars = { health = 3, ammo = 6 }`. An entity in a room may override them (`vars = { health = 5 }`), and rules read and change them: `self.health` in an expression, `setVar` and `addVar` as actions. `state` is a var every instance has, `"idle"` to begin @@ -130,12 +1176,12 @@ A rule's `on` says what triggers it: |=== | `frame` | every frame, the default | `pressed`, `released` | a key or a switch, once per press; `key` or `switch` narrows it -| `collision` | an instance of kind `a` began touching one of kind `b`; `self` is `a`, `other` is `b` +| `collision` | an instance of type `a` began touching one of type `b`; `self` is `a`, `other` is `b` | `hit`, `miss` | a gun's shot landed on `self` (`other` is the gun), or landed on nothing; `event.offscreen` says whether a miss left the picture | `death`, `spawn` | `self`'s health reached zero; `self` was just made | `timer` | a timer on `self` went off; `event.name` says which | `frameReached` | the disc passed `frame` -| `enter`, `leave` | an instance of kind `b` entered or left a trigger of kind `a` +| `enter`, `leave` | an instance of type `b` entered or left a trigger of type `a` | `arrived` | a seeking instance reached its target | `stopped`, `railEnd` | a rail camera reached a stop (`event.name`), or the end of its track | `animationDone` | a clip that does not loop ended on `self` @@ -144,7 +1190,7 @@ A rule's `on` says what triggers it: The event's parameters are written on the rule itself (`on = "collision", a = "shot", b = "raider"`). `each = "raider"` runs a frame rule once per live -raider, and narrows an event rule to that kind; `room = "hall"` keeps a rule +raider, and narrows an event rule to that type; `room = "hall"` keeps a rule to one room. === Expressions @@ -160,7 +1206,7 @@ built -- and the `lua` action is the way past it. | `hero.x` | a placed entity's field or var, by id | `score`, `lives` | the game's vars | `time`, `frame` | seconds since the game began; the disc frame -| `count("raider")` | live instances of a kind +| `count("raider")` | live instances of a type | `random(a, b)`, `distance(a, b)`, `has("key")` | a number in a range; between two instances; whether the inventory holds an item | `abs`, `min`, `max`, `floor` | arithmetic |=== @@ -191,7 +1237,7 @@ tracks = { } ---- -Between keys the box is interpolated; outside them there is none. A kind with +Between keys the box is interpolated; outside them there is none. A type with `{ kind = "hitbox", track = "bandit" }` is hit where the track says, which is how a light gun game over video is 2,750 rectangles no longer. @@ -209,7 +1255,7 @@ which is what House of the Dead does at every doorway. === Games in the scene -A game with a `scene3d` layer places its kinds in world units and draws them +A game with a `scene3d` layer places its types in world units and draws them through the engine's renderer. A `model` look is a glTF file with a scale and a starting clip; `mesh` is a box, sphere, cylinder, or plane with a colour; `light` and `billboard` are what they say. The 2D looks still draw on the @@ -242,7 +1288,7 @@ same nouns. *Walking.* A 2D room declares `walk = { { x, y, x, y, ... }, ... }`, its walkable floor as polygons in overlay coordinates; a 3D room declares `navFrom = { "floor", "ledge" }`, the entities whose meshes are its floor. -Either is baked into a navigation mesh when the room is entered, and a kind +Either is baked into a navigation mesh when the room is entered, and a type with a `walker` behaviour walks it: `walkTo` sends it to a point (and waits), `walkToPointer` to where the player clicked, `walkToHotspot` to a hotspot's walk point; it raises `arrived`, and its state is `walk` or `idle` as it @@ -261,7 +1307,7 @@ viewport keeps occluders visible so they can be placed; the game hides them. `testScripts/author/painted.game` and scene 79 show a character crossing a painted room behind such a pillar. -*Hotspots.* A kind with `{ kind = "hotspot", name = "door", walkX = 600, +*Hotspots.* A type with `{ kind = "hotspot", name = "door", walkX = 600, walkY = 330 }` is something a verb can be used on: its look's box, or a `polygon` of x,y pairs, in 2D; a box the size of its look, met by the ray through the pointer, in 3D. The name is what the sentence line shows. @@ -331,11 +1377,11 @@ Sierra death: a line, a fade, and the game over from the start. `testScripts/author/adventure2d.game` is the two-room adventure in a painted room, and `adventure3d.game` is the same game in a modelled room from a fixed camera -- it takes the 2D description's rules, dialogue, verbs, and parser as -they are and supplies only kinds and rooms. Scenes 64 and 65 play them. +they are and supplies only types and rooms. Scenes 64 and 65 play them. === Vehicles, crowds, turrets, and physics toys -`vehicle` puts a kind on the engine's vehicle physics -- a car, a motorcycle, +`vehicle` puts a type on the engine's vehicle physics -- a car, a motorcycle, a tank, or a boat -- with wheels hung at the corners its look's size gives, driven by the `keys` vars (`dy` throttle, up is forward; `dx` steering), and `self.speed` says how fast. `racer` drives a vehicle round a track of @@ -347,7 +1393,7 @@ is what sits behind the car. camera) across the navigation mesh, asking again every so often -- a crowd of enemies in a shooter, a companion. `patrol` walks a list of points in turn, looping or stopping, and raises `patrolEnd` at the last: a creep's lane, a -guard's round. `turret` fires at the nearest instance of a kind within range, +guard's round. `turret` fires at the nearest instance of a type within range, no faster than its rate, spawning a projectile aimed at it (or doing the damage itself), and `spawnAtPointer` makes an instance where the player clicked -- on the floor the ray meets, in 3D -- which is how turrets are @@ -368,7 +1414,7 @@ examples; scenes 66 and 67 play them. A `branches` track is Dragon's Lair: each branch is a window of disc frames, the `move` (a key) or `switch` it wants, and the frames the disc goes to on -`success` and on `fail`; a kind with `{ kind = "branching", track = "moves" }` +`success` and on `fail`; a type with `{ kind = "branching", track = "moves" }` watches it and raises `branchOpen` (with `event.move`), `branchTaken`, and `branchMissed`. Lives, credits, and continues are vars and rules -- `credit` on `SWITCH_COIN1`, a `restart` on `SWITCH_START1` when `credits > 0` -- and a @@ -386,7 +1432,7 @@ lives spent, the score sent, a coin, and a continue. `distance(a, b)` takes instances or ids (`distance(self, "hero")`) and measures through the scene in 3D. A rule with `each` on an event that has no instance of its own -- a key, a room, the disc -- runs once per instance of -the kind, as a frame rule does, which is how "a swing of the sword hits every +the type, as a frame rule does, which is how "a swing of the sword hits every enemy in reach" is one rule. A `solid` with `moving = true` is a body that follows wherever the instance is moved: a paddle, a lift. @@ -439,10 +1485,14 @@ that manifest. The parameter types: `number` (a number or an expression), `expression`, `string`, `boolean`, `entity` (`"self"`, `"other"`, or an entity's id), -`kind`, `scancode` (a key name from `SCANCODE`), `switch` (a `SWITCH_*` name), +`type`, `scancode` (a key name from `SCANCODE`), `switch` (a `SWITCH_*` name), `file`, `state`, `track`, `room`, and `lua`. A behaviour's parameters are plain values in the description; key and switch names among them are resolved -when the game runs. +when the game runs. An action or condition may also carry `defaults`, the +value a parameter takes when the description leaves it out (a `shake` of +`0.3` for `0.4` seconds), and `optional`, the parameters that mean something +by their absence (a `goTo` with no `x`); a parameter with neither, left +empty, is reported by the checker as *empty* and compiles to `nil`. include::ForgeVocabulary.adoc[] @@ -494,41 +1544,94 @@ reach. === The four panels -`Tab` cycles the panel through the room's *entities*, the *kinds*, the -*rules*, the room's *tracks*, and the *dialogues*. Whatever is selected is edited the same way: -`Enter` walks its fields and each is typed; `Enter` again moves to the next, -`Esc` puts the value back. A typed number comes back a number, because `100` -and `"100"` compile to different source. +`Tab` cycles the panel through the room's *entities*, the *types*, the +*rules*, the room's *tracks*, and the *dialogues*; the digits `1` to `5` go +straight to one. Whatever is selected is edited the same way: `Enter` walks +its fields and each is typed; `Enter` again moves to the next, keeping what +was typed and leaving alone a field passed over with nothing typed; `Esc` +puts the value back and leaves the form; `Delete` while typing empties the +field, which for an action's `entity` means the game itself. A typed number +comes back a number, because `100` and `"100"` compile to different source. +The last line of the box under the list is Forge's message: what just +happened. A value is typed in its own row: it starts selected whole, so +typing replaces it, and kbd:[Left], kbd:[Right], kbd:[Home], and kbd:[End] +put a caret into it to edit it instead; kbd:[Backspace] takes out the +character before the caret, or the whole selected value, and kbd:[Delete] +the character after it -- or, on the value still selected whole, empties +the field for good. Shifted keys type what +they say -- a capital, a quote. The game's name at the top of the panel, +or a press on an empty spot of the canvas, selects nothing, which is the +game's and the room's own form. The panel's list and its fields box each +scroll, with a bar and the wheel, when there is more than fits, and the +chosen row and the field being edited are kept in view. The mouse does the +same work: a click on a row of the list selects what it names -- an entity, a +type, a rule or one of its parts, a track or a key, a dialogue's node or +choice -- a click on a field in the box below starts typing that field, and +a click on a picker's row takes it. -*Entities*: `A` adds one of the selected kind in the middle of the canvas, +*Entities*: `A` adds one of the selected type in the middle of the canvas, `D` duplicates the selected one, `Delete` removes it, and dragging moves it. -Its fields are `id`, `kind`, `x`, `y`, `z`, `scale` -- and in a 3D room -`rx`, `ry`, `rz`, its turn about each axis in degrees -- and each var its kind +Its fields are `id`, `type`, `x`, `y`, `z`, `scale` -- and in a 3D room +`rx`, `ry`, `rz`, its turn about each axis in degrees -- and each var its type declares, overridable here. A scale scales the look, the body, and how big the instance counts as for touching. Renaming an entity renames it in every rule that talks about -it. `PgUp` and `PgDn` move between rooms; `N` adds a room. With nothing -selected, `Enter` edits the room itself: its `name`, `reset`, `depthSort`, -`walk` areas (polygons of x,y pairs, separated by semicolons, drawn on the -canvas), `navFrom`, and `scaleBy` (as `300:0.6, 460:1`). +it. In a 3D room `A` puts the new entity on the floor under the middle of +the viewport, and positions are world units to a tenth. `PgUp` and `PgDn` +move between rooms; `N` adds a room. With nothing selected, `Enter` edits +the game and then the room: the game's `title`, `players`, `vars` (as +`score=0, lives=3`), `verbs`, and `layers` (a list of names, as `disc, +overlay`) with each layer's own parameters as `disc.file` or `scene3d.fov` +-- a table parameter is typed as text: a map such as `scene3d.ambient` as +`r=80, g=80, b=100`, a list of points such as `spawner.points` as +`100,-20; 300,-20`, a list of records such as `target.zones` as +`name=head, bone=b_Head_05, w=0.5, h=0.5, d=0.5; name=body, bone=b_Spine, w=0.8` +(one record per semicolon), a map of lists such as `parser.words` as +`look=look|examine|l, take=take|get`, and a list of words such as +`parser.ignore` as `the; a; an` -- then the room's `name`, `reset`, `depthSort`, `walk` areas (polygons of +x,y pairs, separated by semicolons, drawn on the canvas), `navFrom`, and +`scaleBy` (as `300:0.6, 460:1`). Typing a layer, or a behaviour, away and +back keeps its values. -In every picker, typing narrows the list to the names that contain what was -typed, and `Backspace` widens it again. A field that names a file -- a look's -image or model, a sound, a HUD document -- opens a picker of the files under -the game's directory instead of a prompt; `Esc` on it leaves the field to be -typed. +A field whose values are a fixed set opens a *dropdown* under its own row +instead of a prompt: a file (a look's image or model, a sound, a +HUD document, a layer's disc) lists the files under the game's directory +and in the kit (`Forge/kit/`); a flag lists `true` and `false`; a key +lists the scancodes and a switch the switches; a type's `look` lists the +looks and a rule's `on` the events, each with its line of help; a word +parameter lists the words it takes, as the vocabulary tables say (`anchor` +is `feet`, a camera's `mode` is `fixed`, `follow`, `first`, `orbit`, or +`rail`); and a type, a room, an entity, a state, a track, or a dialogue's +node lists the ones the game has. The list opens on the value the field +has (a value the list does not know is offered as it is), or on `(none)` +when it has none, so `Enter` straight away keeps things as they are: +`Enter` on a row takes it and moves on to the next field, as `Enter` on a +typed value does, and a click takes it and stays. `(none)` leaves the +field empty -- the default, or the game itself for an action's entity. +Typing narrows the list to the names +that contain what was typed -- an exact name first, then names that begin +with it -- and `Backspace` widens it again; a long list shows a window of +rows around the chosen one, and says how many lie past it. A dropdown +opens upward when there is no room below its row. The pickers +that add a condition, an action, or an event (`C`, `T`, `E`) take the +panel's list over instead, since the whole manifest is being chosen from. Where the game's own names are only +suggestions -- a rule may name a type, a room, or an entity that is made +later, and a state can be any word -- what was typed is offered at the top +of the list, so `Enter` takes a new word as readily as a known one. `Esc` +on a picker leaves the field to be typed. -*Kinds*: `A` adds one, `D` duplicates, `Delete` removes one no room still +*Types*: `A` adds one, `D` duplicates, `Delete` removes one no room still places. Its fields are `name`, `look` and what that look takes (`look.w`, `look.file`, ...), `vars` typed as `health=3, ammo=6`, `behaviours` typed as a list of names (`keys, mover, shooter`), and each behaviour's own parameters as `mover.speed` and so on. The fields come from the manifest, so a new look or -behaviour is editable the moment it is declared; renaming a kind renames it in +behaviour is editable the moment it is declared; renaming a type renames it in every entity and rule. In a 2D room, `V` draws a polygon corner by corner on the canvas: with the -selected entity's kind carrying a `hotspot` behaviour it becomes that hotspot's -outline, otherwise it is added to the room's walk areas. `V` or `Enter` closes -it, `Esc` drops it. +selected entity's type carrying a `hotspot` behaviour it becomes that hotspot's +outline, otherwise it is added to the room's walk areas. The panel steps +aside while the corners are pressed. `V` or `Enter` closes it, `Esc` drops +it. *Rules*: the selected rule opens in place with its conditions and actions under it, because a rule only means anything whole. `N` adds a rule, `E` picks @@ -546,7 +1649,13 @@ key is moved by dragging its box on the canvas or by typing. `Delete` removes the selected key, or the track. In a 3D room a new track is a rail of points, drawn through the scene with its stops marked, and `K` puts a point where the editor camera stands, looking where it looks: stand where the player should -and press it. +and press it. `W` adds a *waves* track, whose keys are spawns: `at`, the +`type` made, `count` of them, `every` so many seconds apart, and where -- +`from`, an entity or a comma-separated list of entities taken in turn, or +`x`, `y`, `z`. A waves track keyed by `stop` instead of `frame` or `time` +ties its waves to a rail camera's stops: `at` names the stop. A wave +begins once per visit to the room, and each instance it makes raises `spawn` +as any other. *Dialogues*: an outline of every dialogue, the selected one's nodes under it, the selected node's choices under that, and a choice's actions under that. @@ -557,20 +1666,22 @@ node -- `T` adds an action to the selected choice from the vocabulary, and `next`, and `once` are typed like everything else. `P` plays from the room on screen: the built game begins in it, with the -other rooms following in order. +other rooms following in order. `X` releases the game into a folder named +for it in Forge's data directory, with everything it needs (see +<<_releasing_a_game>>). On the 2D canvas the selected entity carries the yellow turn handle alone: an arm the way its `rz` points, past its box, dragged round the entity to turn it, and a sheet entity shows its first frame turned the same way. In -the kinds panel each kind's picture stands beside its name -- the sprite +the types panel each type's picture stands beside its name -- the sprite itself (one frame of a sheet), `Aa` for a text, or a swatch of the box's -colour -- so a kind can be told from its neighbours without opening it. +colour -- so a type can be told from its neighbours without opening it. `testScripts/scene82.singe` drives both. === The 3D viewport A room in a game with a `scene3d` layer is shown as the scene itself, built -from the kinds' looks exactly as the runtime builds them, under an editor +from the types' looks exactly as the runtime builds them, under an editor camera that orbits whatever is selected: `J` and `L` orbit, `Y` and `H` tilt, `I` and `K` close in and back off. Nothing steps -- no behaviours, no physics -- so it is the description that is on screen, not a running game. An entity @@ -586,14 +1697,16 @@ drive it. [cols="1,4"] |=== -| `Tab` | entities, kinds, rules, tracks +| `Tab`, `1` to `5` | entities, types, rules, tracks, dialogues | Up, Down | move through the list, and through the parts of the open rule or track -| Left, Right | slide the panel +| Left, Right | slide the panel; while typing, move the caret (with `Home` and `End`) | `PgUp`, `PgDn` | the previous or next room | `Enter` | type a value for whatever is selected; again for its next value -| `Esc` | put the value back; otherwise close the description -| `A`, `D`, `Delete` | add, duplicate, delete an entity or a kind +| `Delete` | while typing, take out the character after the caret, or empty the field while its value is still selected whole +| `Esc` | put the value back and leave the form; otherwise close the description +| `A`, `D`, `Delete` | add, duplicate, delete an entity or a type | `N` | a new rule, a new track, or (in the entities) a new room +| `W` | a new waves track (in the tracks) | `E` | pick what the rule is on | `C`, `O`, `T` | add a condition, an any-of condition, an action | `[`, `]` | move the rule up or down; in the tracks, leap the cursor @@ -602,11 +1715,11 @@ drive it. | `K` | a key at the cursor; in a 3D room, a rail point where the camera stands | `J`, `L`, `Y`, `H`, `I`, `K` | in a 3D room: orbit, tilt, and zoom the editor camera | `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history -| `S`, `B`, `P` | save, build, play +| `S`, `B`, `P`, `X` | save, build, play, release |=== From a script the same work is `forgeEntityAdd`, `forgeEntityDuplicate`, -`forgeEntityDelete`, `forgeEntitySet`, `forgeKindAdd`, `forgeKindSet`, +`forgeEntityDelete`, `forgeEntitySet`, `forgeTypeAdd`, `forgeTypeSet`, `forgeRuleNew`, `forgeRuleOn`, `forgeRuleAdd("when"|"any"|"act", name)`, `forgePartSet`, `forgePartDelete`, `forgeRuleMove`, `forgeTrackNew`, `forgeKeyAdd`, `forgeCursorTo`, `forgeUndo`, and `forgeRedo`. @@ -617,11 +1730,20 @@ and `testScripts/scene54.singe` asserts it. === Releasing a game -`forgeExport(folder, name)` writes everything a finished game needs into a -directory of its own: the compiled script, a `games.dat` so the menu lists it, -the description it was built from so it can be opened again, and **a copy of -the runtime**, taken out of Forge. `--pack` turns that directory into a `.game` -like any other, and it runs on a machine that has never had Forge on it. +`X` in the editor, or `forgeExport(folder, name)` from a script, writes +everything a finished game needs into a directory of its own: the compiled +script, a `games.dat` so the menu lists it (its names begin with the folder's +own, `CoinRun/CoinRun.singe`, which is how both the menu and a packed copy +address them), the description it was built from +so it can be opened again, **a copy of the runtime**, taken out of Forge, and +**every file the description names** -- looks, sounds, music, the disc, models, +a HUD document -- at the same relative name it had, so the kit's +`Forge/kit/hero.png` lands in `Forge/kit/` under the release. The engine's +own files (`Singe/...`) and absolute paths are left where they are. The +compiled script notes its own directory in `AUTHOR_DIR`, and the runtime looks +for every named file there first, so the release runs wherever it is put. +`--pack` turns that directory into a `.game` like any other, and it runs on a +machine that has never had Forge on it. Every built game finds its own directory to load that runtime from, using `debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the @@ -630,6 +1752,6 @@ launcher, a preview -- would otherwise look beside the caller. `forgeBuild(path)` compiles what is on screen (and puts the runtime beside it, so the result plays); what `authorCheck` has to say about the description -- -a kind that is not declared, an expression that does not parse, a field a +a type that is not declared, an expression that does not parse, a field a look, a behaviour, or an event does not take (a misspelling, nine times in ten) -- goes to the console and the panel. diff --git a/docs/ForgeVocabulary.adoc b/docs/ForgeVocabulary.adoc index b89028733..ebdb831a9 100644 --- a/docs/ForgeVocabulary.adoc +++ b/docs/ForgeVocabulary.adoc @@ -52,7 +52,7 @@ | `box` | A filled rectangle, centred on the instance -- or standing on it, with anchor feet. -| `anchor` (string), `b` (number), `g` (number), `h` (number), `r` (number), `w` (number) +| `anchor` (feet), `b` (number), `g` (number), `h` (number), `r` (number), `w` (number) | `grid` | A map of tiles from a sheet, its top left at the instance: columns is the sheet's width in tiles, tile the tile's size, map the rows of tile numbers (1 is the first; 0 is empty), rows separated by semicolons. @@ -60,11 +60,11 @@ | `light` | A point or spot light, or a sun, on the instance. -| `b` (number), `g` (number), `intensity` (number), `r` (number), `range` (number), `shadow` (boolean), `type` (string) +| `b` (number), `g` (number), `intensity` (number), `r` (number), `range` (number), `shadow` (boolean), `type` (point, spot, sun) | `mesh` | A box, sphere, cylinder, or plane with a colour or a texture. Floors, walls, crates, targets. An occluder is not seen but hides what is behind it: a pillar that is only in the painting. -| `b` (number), `d` (number), `g` (number), `h` (number), `metallic` (number), `occluder` (boolean), `r` (number), `roughness` (number), `shape` (string), `texture` (file), `unlit` (boolean), `w` (number) +| `b` (number), `d` (number), `g` (number), `h` (number), `metallic` (number), `occluder` (boolean), `r` (number), `roughness` (number), `shape` (box, sphere, cylinder, plane), `texture` (file), `unlit` (boolean), `w` (number) | `model` | A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as. @@ -80,7 +80,7 @@ | `sprite` | An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. -| `anchor` (string), `faces` (string), `file` (file), `frames` (number) +| `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number) | `text` | A line of text, its top left at the instance. The var text is what it says. @@ -104,7 +104,7 @@ | `body` | A thing physics moves: a crate, a ball, debris. Mass, bounce, friction, and buoyancy as Jolt takes them. -| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (string) +| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere) | `branching` | Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. Raises branchTaken and branchMissed. @@ -112,7 +112,7 @@ | `camera` | The scene is drawn from this instance. fixed looks at a point; follow keeps an offset from a target; first sits on a target's eyes and the mouse looks; orbit circles a target; rail rides a track of points with stops. -| `eye` (number), `lag` (number), `lookX` (number), `lookY` (number), `lookZ` (number), `mode` (string), `target` (string), `track` (track), `x` (number), `y` (number), `z` (number) +| `eye` (number), `lag` (number), `lookX` (number), `lookY` (number), `lookZ` (number), `mode` (fixed, follow, first, orbit, rail), `target` (string), `track` (track), `x` (number), `y` (number), `z` (number) | `character` | Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera. @@ -128,7 +128,7 @@ | `gun` | The pointer as a gun for one player: a press of the trigger raises hit on what is under it, or miss. Ammo counts down; a shot off the screen reloads. With aim centre it fires from the middle of the picture -- a first-person gun. -| `aim` (string), `ammo` (number), `player` (number), `reload` (string), `trigger` (switch) +| `aim` (centre), `ammo` (number), `player` (number), `reload` (offscreen), `trigger` (switch) | `health` | The var health; the damage action lowers it, and at zero the instance raises death and is destroyed unless keep is set. With ragdoll, a model falls limp instead and goes after linger seconds. @@ -144,7 +144,7 @@ | `joint` | Joins this body to another's -- or to the world, with no other -- by a hinge, a ball, or a slider through a world point along an axis. -| `ax` (number), `ay` (number), `az` (number), `dx` (number), `dy` (number), `dz` (number), `other` (string), `type` (string) +| `ax` (number), `ay` (number), `az` (number), `dx` (number), `dy` (number), `dz` (number), `other` (string), `type` (hinge, ball, slider) | `keys` | Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware. @@ -159,7 +159,7 @@ | `loop` (boolean), `points` (table) | `platformer` -| Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. +| Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. Its point is its feet, so its look stands on it. Sets the var facing, and the var state to idle, walk, jump, or fall unless a rule set another. | `jump` (number), `speed` (number) | `projectile` @@ -175,12 +175,12 @@ | `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string) | `shooter` -| Spawns a kind from an offset while the var fire is set (or always, with auto), no faster than the rate. -| `auto` (boolean), `offsetX` (number), `offsetY` (number), `rate` (number), `spawns` (kind) +| Spawns a type from an offset while the var fire is set (or always, with auto), no faster than the rate. +| `auto` (boolean), `offsetX` (number), `offsetY` (number), `rate` (number), `spawns` (type) | `soft` | A mesh look made soft: a cloth, or a body inflated to a pressure. -| `mass` (number), `pressure` (number), `stiffness` (number), `type` (string) +| `mass` (number), `pressure` (number), `stiffness` (number), `type` (cloth, body) | `solid` | Immovable ground or a wall, in a world2d or a scene3d room. With moving, a body that follows wherever the instance is moved -- a paddle, a lift. @@ -191,8 +191,8 @@ | `death` (file), `hit` (file), `pressed` (file), `spawn` (file), `volume` (number) | `spawner` -| Makes instances of a kind every so many seconds at its own position or at one of its points, up to max alive at once and total in all. -| `every` (number), `max` (number), `pick` (string), `points` (table), `spawns` (kind), `total` (number) +| Makes instances of a type every so many seconds at its own position or at one of its points, up to max alive at once and total in all. +| `every` (number), `max` (number), `pick` (turn, random), `points` (table), `spawns` (type), `total` (number) | `spin` | Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second. @@ -215,12 +215,12 @@ | -- | `turret` -| Fires at the nearest instance of a kind within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself. -| `damage` (number), `range` (number), `rate` (number), `spawns` (kind), `speed` (number), `targets` (kind) +| Fires at the nearest instance of a type within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself. +| `damage` (number), `range` (number), `rate` (number), `spawns` (type), `speed` (number), `targets` (type) | `vehicle` | A car, motorcycle, tank, or boat on the engine's vehicle physics, driven by the vars dy (throttle, up is forward) and dx (steering) from keys. Wheels hang at the four corners the sizes say; a boat needs none. -| `mass` (number), `radius` (number), `suspension` (number), `thrust` (number), `torque` (number), `type` (string), `wheelX` (number), `wheelZ` (number), `width` (number) +| `mass` (number), `radius` (number), `suspension` (number), `thrust` (number), `torque` (number), `type` (car, motorcycle, tank, boat), `wheelX` (number), `wheelZ` (number), `width` (number) | `walker` | Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes. @@ -240,11 +240,11 @@ | `animationDone` | A clip that does not loop ended on self; event.state says which state it was for. -| `kind` (kind), `state` (state) +| `state` (state), `type` (type) | `arrived` | A seeking instance reached its target. -| `kind` (kind) +| `type` (type) | `branchMissed` | The window closed without the move and the disc went to the fail frame. @@ -259,16 +259,16 @@ | -- | `collision` -| An instance of kind a began touching one of kind b (self is a, other is b). -| `a` (kind), `b` (kind) +| An instance of type a began touching one of type b (self is a, other is b). +| `a` (type), `b` (type) | `death` | self's health reached zero. -| `kind` (kind) +| `type` (type) | `enter` -| An instance of kind b entered a trigger of kind a (self is the trigger, other what entered). -| `a` (kind), `b` (kind) +| An instance of type b entered a trigger of type a (self is the trigger, other what entered). +| `a` (type), `b` (type) | `frame` | Every frame. @@ -284,11 +284,11 @@ | `hit` | A gun shot landed on self; other is the gun. -| `kind` (kind), `player` (number) +| `player` (number), `type` (type) | `leave` -| An instance of kind b left a trigger of kind a. -| `a` (kind), `b` (kind) +| An instance of type b left a trigger of type a. +| `a` (type), `b` (type) | `midi` | A MIDI note came in on the port the game opened; event.pitch and event.velocity say which and how hard. (pitch, since note is what a rule's comment is called.) @@ -300,7 +300,7 @@ | `patrolEnd` | A patrolling instance reached the last of its points. -| `kind` (kind) +| `type` (type) | `pressed` | A key or a switch went down. @@ -326,9 +326,13 @@ | A line was typed at the parser: its verb, noun, and second noun as the words table knows them, or unknown for a word it does not. The most specific rule wins. | `noun` (string), `second` (string), `verb` (string) +| `soundDone` +| A clip that playSound or a sound behaviour started has ended; event.file says which, and self is the instance that played it. +| `file` (file), `type` (type) + | `spawn` | self has just been made. -| `kind` (kind) +| `type` (type) | `stopped` | A rail camera reached a stop; event.name says which. pathNext moves it on. @@ -336,7 +340,7 @@ | `timer` | A timer on self went off; event.name says which. -| `kind` (kind), `name` (string) +| `name` (string), `type` (type) | `verb` | A verb was used on a hotspot (self), with an item or not: "use key on door". The most specific rule wins. @@ -624,13 +628,13 @@ | | `spawn` -| Make an instance of a kind at a point. -| `kind` (kind), `x` (number), `y` (number), `z` (number) +| Make an instance of a type at a point. +| `type` (type), `x` (number), `y` (number), `z` (number) | | `spawnAtPointer` -| Make an instance of a kind where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D. -| `kind` (kind), `player` (number) +| Make an instance of a type where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D. +| `player` (number), `type` (type) | | `stopMusic` diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 8e46b8609..58bcf0e47 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -7170,7 +7170,7 @@ guiDraw(gui, x, y) guiDraw(gui, x, y, width, height) ---- -Composites the GUI's texture over the overlay for this frame, in overlay coordinates, so it obeys the Sinden border and the overscan scale like everything else on the overlay: covering the whole overlay with one argument, at its own size with a corner at `x, y`, or scaled into the `width` by `height` rectangle. The call lasts one frame; make it from `onOverlayUpdate` (or `singeMain`) every frame the GUI should be visible. GUIs draw above the overlay in the order called (the last on top), under `PARTICLE_OVER` particles and the pause indicator, and the GUI drawn last under the pointer is the one that receives the mouse. Up to sixteen calls are honored per frame; more are ignored. Any other argument count raises `Expected 1, 3 or 5 arguments`. +Composites the GUI's texture over the overlay for this frame, in overlay coordinates, so it obeys the Sinden border and the overscan scale like everything else on the overlay: covering the whole overlay with one argument, at its own size with a corner at `x, y`, or scaled into the `width` by `height` rectangle. The call's place in the script's drawing decides what is under the GUI and what is over it: everything drawn on the overlay before it stays underneath, and everything drawn after it -- until the next `guiDraw`, or the script's next turn -- goes on a layer of its own composited over that GUI. A pointer drawn after the HUD is drawn over the HUD; a second document drawn after that sits over the pointer, with its own layer above. Up to sixteen such layers a frame; a call past that draws its GUI with nothing over it. `overlayClear` clears whichever layer is being drawn on, and `overlaySetMonochrome` greys the base overlay only. The call lasts one frame; make it from `onOverlayUpdate` (or `singeMain`) every frame the GUI should be visible. GUIs draw above the overlay in the order called (the last on top), under `PARTICLE_OVER` particles and the pause indicator, and the GUI drawn last under the pointer is the one that receives the mouse. Up to sixteen calls are honored per frame; more are ignored. Any other argument count raises `Expected 1, 3 or 5 arguments`. *Parameters:* @@ -7183,11 +7183,14 @@ Composites the GUI's texture over the overlay for this frame, in overlay coordin .Example [source,lua] ---- --- The HUD sits in the bottom right corner at a third of the overlay's width. +-- The HUD sits in the bottom right corner at a third of the overlay's width, and the gun's +-- sight is drawn after it, so the sight is never hidden by the panel. function onOverlayUpdate() overlayClear() guiSetValue(hud, hudPage, "lives", tostring(lives)) guiDraw(hud, overlayGetWidth() * 0.66, overlayGetHeight() * 0.8, overlayGetWidth() * 0.33, overlayGetHeight() * 0.2) + local x, y = mouseGetPosition(0) + spriteDraw(sight, x, y, true) return OVERLAY_UPDATED end ---- @@ -7457,7 +7460,7 @@ guiSetHandler(gui, document, id, event, function) guiSetHandler(gui, document, id, event, nil) ---- -Calls `function(gui, document, id, event, value)` whenever the element with that `id` fires the named event: `"click"` for buttons and rows, `"change"` for form controls, `"submit"` for a form, or any other RmlUi event name (`"focus"`, `"mouseover"`, `"keydown"`, ...). `value` is the element's value at that moment as a string, as `guiGetValue` would return it (a range comes as RmlUi formats it, `"85.000000"`, so pass it through `tonumber`). One function is kept per element and event; setting another replaces it, and `nil` removes it. Handlers run as the events happen, between the game's own callbacks, and an error in one ends the game like an error in any callback. Ids are at most 63 characters. Raises `No element "id" in document N of GUI G` when the element does not exist, and `Argument 5 must be a function or nil` for anything else in that place. +Calls `function(gui, document, id, event, value)` whenever the element with that `id` fires the named event: `"click"` for buttons and rows, `"change"` for form controls, `"submit"` for a form, or any other RmlUi event name (`"focus"`, `"mouseover"`, `"keydown"`, ...). `value` is the element's value at that moment as a string, as `guiGetValue` would return it (a range comes as RmlUi formats it, `"85.000000"`, so pass it through `tonumber`). One function is kept per element and event; setting another replaces it, and `nil` removes it. The handler belongs to the `id`, not to one element: when the element is replaced -- a list rebuilt through `inner_rml` makes new elements -- set the handler again after the rebuild and it attaches to the element that carries the id now. Handlers run as the events happen, between the game's own callbacks, and an error in one ends the game like an error in any callback. Ids are at most 63 characters. Raises `No element "id" in document N of GUI G` when the element does not exist, and `Argument 5 must be a function or nil` for anything else in that place. *Parameters:* @@ -7489,7 +7492,7 @@ end) guiSetInput(gui, enabled) ---- -Whether the mouse and light gun, keys, typed text and the framework switches from a pad or mouse reach the GUI. A new GUI only displays, which suits a HUD or a sign; turn input on for a menu or a form. While it is on, the pointer reaches the GUI wherever it is drawn flat (the last drawn on top) or shown on a surface in the scene, every key goes to it, a text field with focus turns on SDL's text input, and the pad's directions arrive as arrow keys with `ACTION_1` as Return and `ACTION_2` as Escape. What an element uses never reaches `onInputPressed`, `onKeyPressed` or the mouse callbacks; what nothing used falls through to them as usual. Raises an error for a handle that is not valid. +Whether the mouse and light gun, keys, typed text and the framework switches from a pad or mouse reach the GUI. A new GUI only displays, which suits a HUD or a sign; turn input on for a menu or a form. While it is on, the pointer reaches the GUI wherever it is drawn flat (the last drawn on top) or shown on a surface in the scene, every key goes to it except `Tab`, which the GUI is given only while a text field is being typed in (RmlUi uses it to move focus and never hands it back, and a game whose keys include `Tab` would otherwise lose it), a text field with focus turns on SDL's text input, and the pad's directions arrive as arrow keys with `ACTION_1` as Return and `ACTION_2` as Escape. What an element uses never reaches `onInputPressed`, `onKeyPressed` or the mouse callbacks; what nothing used falls through to them as usual. Raises an error for a handle that is not valid. *Since:* 3.00. *See also:* <>, <>, <> @@ -7994,7 +7997,7 @@ end keyboardSetMode(mode) ---- -Switches between the two keyboard models. In `MODE_NORMAL` only keys mapped in `controls.cfg` reach the script, as `SWITCH_*` values, key repeat is dropped so each press arrives once, and the engine acts on its own switches (pause, quit, screenshot, mouse grab). In `MODE_FULL` every key reaches `onInputPressed` and `onInputReleased` as its keysym and `onKeyPressed` and `onKeyReleased` as keysym and scancode, key repeat is delivered for text entry, and keyboard mappings of the engine's own switches are ignored so that the game keeps every key; controller and mouse button mappings still act. Most games set the mode once at startup; a game with text entry switches to `MODE_FULL` for the duration and back. Any other value aborts the script. +Switches between the two keyboard models. In `MODE_NORMAL` only keys mapped in `controls.cfg` reach the script, as `SWITCH_*` values, key repeat is dropped so each press arrives once, and the engine acts on its own switches (pause, quit, screenshot, mouse grab). In `MODE_FULL` every key reaches `onInputPressed` and `onInputReleased` as its keysym and `onKeyPressed` and `onKeyReleased` as keysym and scancode, the text a press produces reaches `onTextInput`, key repeat is delivered for text entry, and keyboard mappings of the engine's own switches are ignored so that the game keeps every key; controller and mouse button mappings still act, and those buttons still reach the script as their `SWITCH_*` values, since they cannot be typed. Most games set the mode once at startup; a game with text entry switches to `MODE_FULL` for the duration and back. Any other value aborts the script. In `MODE_FULL` the keysyms of four keys are also switch numbers -- Backspace is `8` (`SWITCH_BUTTON3`), Tab `9` (`SWITCH_COIN1`), Return `13`, and Escape `27` -- so a script that reads `onInputPressed` for a pad or mouse switch and also expects those keys should check `keyboardIsDown` for the key before treating the value as the switch. *Parameters:* @@ -14564,9 +14567,10 @@ end [source,text] ---- singeScreenshot() +singeScreenshot(name) ---- -Requests a screenshot of the whole window, letterbox included, taken after the next frame is drawn and saved as a PNG in the game's data directory (see `singeGetDataPath`). Files are named `singe000.png` upward; each script run scans from zero for the first free name and later shots continue past the last one saved. The call forces a redraw so the shot is taken even while the display is idle. The switch mapped to `INPUT_SCREENSHOT` in `controls.cfg` does the same thing. +Requests a screenshot of the whole window, letterbox included, taken after the next frame is drawn and saved as a PNG in the game's data directory (see `singeGetDataPath`). Without a name, files are named `singe000.png` upward; each script run scans from zero for the first free name and later shots continue past the last one saved. With a name the shot goes to that file, `.png` added when the name has no extension, replacing any file of that name; a name with a directory in it aborts the script. Naming the shot is for scripts that produce pictures on purpose, such as the ones that illustrate the Forge tutorials, so a rerun replaces the picture rather than adding one. The call forces a redraw so the shot is taken even while the display is idle. The switch mapped to `INPUT_SCREENSHOT` in `controls.cfg` does the same thing. *Since:* 1.x *See also:* <> @@ -18966,6 +18970,20 @@ Called in `MODE_FULL` only, for every key going down or up, with both the logica *See also:* <>, <>, <> +[#ontextinput] +==== onTextInput + +[source,text] +---- +function onTextInput(text) +end +---- + +Called in `MODE_FULL` only, with the text a key press produced -- a character or a few, in UTF-8 -- after the keyboard's shift state and layout have been applied, so a capital, a quote, or a letter from a non-English layout arrives as the person typed it. `onKeyPressed` gives the same press as its unshifted keysym and scancode and is the one to use for keys that are commands; this is the one to use for keys that are text. A GUI with a focused text field takes the text first, and the script hears nothing of it. + +*Since:* 3.00. +*See also:* <>, <> + .Example [source,lua] ---- diff --git a/docs/images/tutorials/01-chooser.png b/docs/images/tutorials/01-chooser.png new file mode 100644 index 000000000..265096e4b --- /dev/null +++ b/docs/images/tutorials/01-chooser.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c028493859e6552a7b08f19beb280553d9dff4975b707730a08ecfdbc6d39180 +size 35431 diff --git a/docs/images/tutorials/01-entities.png b/docs/images/tutorials/01-entities.png new file mode 100644 index 000000000..e06ddf578 --- /dev/null +++ b/docs/images/tutorials/01-entities.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72b99ef2fdbb3abe118261b258c34f4bcdb6963909fcf23ac45edb0ca6e35933 +size 16685 diff --git a/docs/images/tutorials/01-moved.png b/docs/images/tutorials/01-moved.png new file mode 100644 index 000000000..126958f5f --- /dev/null +++ b/docs/images/tutorials/01-moved.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ec267d81fccbc97b0d1d9d19ec99b2a753da244386032d0acaacbb45e9074f3 +size 11564 diff --git a/docs/images/tutorials/01-playing.png b/docs/images/tutorials/01-playing.png new file mode 100644 index 000000000..f096bf5a3 --- /dev/null +++ b/docs/images/tutorials/01-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ea66dd60665e0028256361daf0cb98ba39a0b36ac9d01b0ea3a46b6ae2725d4 +size 2798 diff --git a/docs/images/tutorials/01-recoloured.png b/docs/images/tutorials/01-recoloured.png new file mode 100644 index 000000000..8a1534c09 --- /dev/null +++ b/docs/images/tutorials/01-recoloured.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c09fadd6444ff287a1ccc4f745dc421d5348647a93ed00caa898ff7e1b8590bd +size 15569 diff --git a/docs/images/tutorials/01-rules.png b/docs/images/tutorials/01-rules.png new file mode 100644 index 000000000..7c23c94e0 --- /dev/null +++ b/docs/images/tutorials/01-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f72fe849d79d868da4bc2f1e961ba264614c78a4d8f425065a6e3140a3673159 +size 17191 diff --git a/docs/images/tutorials/01-saved.png b/docs/images/tutorials/01-saved.png new file mode 100644 index 000000000..6d0bb060c --- /dev/null +++ b/docs/images/tutorials/01-saved.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:695263893b3a514231ae855af7ec1a70aa268a4081641d1f45b351903d2f7fa8 +size 2765 diff --git a/docs/images/tutorials/01-types.png b/docs/images/tutorials/01-types.png new file mode 100644 index 000000000..43becfc74 --- /dev/null +++ b/docs/images/tutorials/01-types.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89d5bcf43e50dc8fb542511e01f71c0e8d9d56456cf2e9fd9ed82280360a306a +size 12874 diff --git a/docs/images/tutorials/02-cave.png b/docs/images/tutorials/02-cave.png new file mode 100644 index 000000000..db346217a --- /dev/null +++ b/docs/images/tutorials/02-cave.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6b90a9099a42e7a2bc47d85eff1c3166b233568737d7eaf33e9c1edf1043f99 +size 11386 diff --git a/docs/images/tutorials/02-coinType.png b/docs/images/tutorials/02-coinType.png new file mode 100644 index 000000000..3fb146720 --- /dev/null +++ b/docs/images/tutorials/02-coinType.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:705ffd60e70776524306167cea83f69c01a74819d9fea53ea16d08d276fb1901 +size 12634 diff --git a/docs/images/tutorials/02-coins.png b/docs/images/tutorials/02-coins.png new file mode 100644 index 000000000..efba558e6 --- /dev/null +++ b/docs/images/tutorials/02-coins.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c90cd154c2fe5b5eded1757a5088d5b76613a69513998b32a4b7bf33d3202e39 +size 14340 diff --git a/docs/images/tutorials/02-placed.png b/docs/images/tutorials/02-placed.png new file mode 100644 index 000000000..bdf7987b1 --- /dev/null +++ b/docs/images/tutorials/02-placed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb7c046008db042e4762b3e59b39b99f18ce4c2bed47b712c816e1c882d4bd33 +size 15727 diff --git a/docs/images/tutorials/02-playing.png b/docs/images/tutorials/02-playing.png new file mode 100644 index 000000000..6ff2ad306 --- /dev/null +++ b/docs/images/tutorials/02-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1dc173bdc619298119d47e2f19f808379e220e0e4456ffaba82bc7b059d33485 +size 3739 diff --git a/docs/images/tutorials/02-rule.png b/docs/images/tutorials/02-rule.png new file mode 100644 index 000000000..c632cb387 --- /dev/null +++ b/docs/images/tutorials/02-rule.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0102401e69465330cd36f95e28291304fe89292d78b72a7af3aadbeb19becb2 +size 19033 diff --git a/docs/images/tutorials/02-rules.png b/docs/images/tutorials/02-rules.png new file mode 100644 index 000000000..ca902633f --- /dev/null +++ b/docs/images/tutorials/02-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66017dfafa69948a4b53449e35f124acf4cbd723fe20cde107d53b047539ce0b +size 18113 diff --git a/docs/images/tutorials/03-coinSpin.png b/docs/images/tutorials/03-coinSpin.png new file mode 100644 index 000000000..2199fb1ac --- /dev/null +++ b/docs/images/tutorials/03-coinSpin.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59f1bd0f049ca7e89c880c981efa59745feecbaf229696f0e0d949f4c96febe1 +size 16785 diff --git a/docs/images/tutorials/03-heroType.png b/docs/images/tutorials/03-heroType.png new file mode 100644 index 000000000..db2c9ec0f --- /dev/null +++ b/docs/images/tutorials/03-heroType.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e961f7c473d259cd70481155f0b1295fd58179d1f5f6d77db1b7c3ffad5cf64 +size 17913 diff --git a/docs/images/tutorials/03-jumpRule.png b/docs/images/tutorials/03-jumpRule.png new file mode 100644 index 000000000..3cd6c3318 --- /dev/null +++ b/docs/images/tutorials/03-jumpRule.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b66a966da8ffdd28c71d94e885df96c5cd9be90aeeb2a681e67d978cce20ab30 +size 20675 diff --git a/docs/images/tutorials/03-music.png b/docs/images/tutorials/03-music.png new file mode 100644 index 000000000..2adf9a229 --- /dev/null +++ b/docs/images/tutorials/03-music.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:895d07d995a11e3972d142aebfb61ea24879518089b949524f39daa0611fbc57 +size 19334 diff --git a/docs/images/tutorials/03-playing.png b/docs/images/tutorials/03-playing.png new file mode 100644 index 000000000..aa8ae34e8 --- /dev/null +++ b/docs/images/tutorials/03-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8648885c982b68256fbc0e8d8587a7b6565b40f23015fd69ae28278f13dffab7 +size 4283 diff --git a/docs/images/tutorials/04-cleared.png b/docs/images/tutorials/04-cleared.png new file mode 100644 index 000000000..aec5b3bb3 --- /dev/null +++ b/docs/images/tutorials/04-cleared.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:caaa77ea90c065fac9eeb0bacd46f988309e065120e875298589097388454c10 +size 14101 diff --git a/docs/images/tutorials/04-hitRule.png b/docs/images/tutorials/04-hitRule.png new file mode 100644 index 000000000..4a69ffedf --- /dev/null +++ b/docs/images/tutorials/04-hitRule.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49344adc0cb26b04c9c89364d91158674c049fb6a7a6bac4a68371cf3d40349c +size 15521 diff --git a/docs/images/tutorials/04-placed.png b/docs/images/tutorials/04-placed.png new file mode 100644 index 000000000..c6c0f9c8e --- /dev/null +++ b/docs/images/tutorials/04-placed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4da81686590dd2d7f879a6a2828ec9f667dc5e749c58ce79883384803ba39e97 +size 13359 diff --git a/docs/images/tutorials/04-playing.png b/docs/images/tutorials/04-playing.png new file mode 100644 index 000000000..649133e05 --- /dev/null +++ b/docs/images/tutorials/04-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:501158b363cafd5dde42320a0c6324ca3205dcced2e5f7cdbb15303a12ffe969 +size 7814 diff --git a/docs/images/tutorials/04-rules.png b/docs/images/tutorials/04-rules.png new file mode 100644 index 000000000..8e9f5f9c9 --- /dev/null +++ b/docs/images/tutorials/04-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:399821a2a36e5ac136847001ed3c26705815d9d1ed8358a13da7d20ebb9d8cf2 +size 21865 diff --git a/docs/images/tutorials/04-ship.png b/docs/images/tutorials/04-ship.png new file mode 100644 index 000000000..6d4d4befb --- /dev/null +++ b/docs/images/tutorials/04-ship.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc1de6397df2334eee95b92e48c70b1cdb2b1156fba1febe9e6db97010172884 +size 21221 diff --git a/docs/images/tutorials/04-types.png b/docs/images/tutorials/04-types.png new file mode 100644 index 000000000..b5aa1c7ce --- /dev/null +++ b/docs/images/tutorials/04-types.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1e57311d37586d59e6f157bac532976cbf5ba5bfb091c0b700fdbb6cda745b1 +size 15872 diff --git a/docs/images/tutorials/05-disc.png b/docs/images/tutorials/05-disc.png new file mode 100644 index 000000000..8518b8e4a --- /dev/null +++ b/docs/images/tutorials/05-disc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b903cd121a50f315b57a23425b3c9f437fecdbcd3c604deca825ed50d31d65b3 +size 18508 diff --git a/docs/images/tutorials/05-playing.png b/docs/images/tutorials/05-playing.png new file mode 100644 index 000000000..0913de59e --- /dev/null +++ b/docs/images/tutorials/05-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff6be9544b1ba3f2f612d7f791dcdd1b5a8531247e6167ae2910fe4eab1411f9 +size 7003 diff --git a/docs/images/tutorials/05-prompts.png b/docs/images/tutorials/05-prompts.png new file mode 100644 index 000000000..1e66eb525 --- /dev/null +++ b/docs/images/tutorials/05-prompts.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d21beac9dd1fa2595b5293ca43bec2dc783a5e35bd7f3c4162120addaba3b27f +size 13023 diff --git a/docs/images/tutorials/05-rules.png b/docs/images/tutorials/05-rules.png new file mode 100644 index 000000000..e76deb20d --- /dev/null +++ b/docs/images/tutorials/05-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6893c6e75e2e59e9e346cbb7d82dc0b4c90b67f61579940229841e9f1a367fb +size 23124 diff --git a/docs/images/tutorials/05-window.png b/docs/images/tutorials/05-window.png new file mode 100644 index 000000000..08faf0d88 --- /dev/null +++ b/docs/images/tutorials/05-window.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:915bdfcfe0ecb969ffd8d89dee385f0c8613ff154dd3b2a396cd13c06c1e0025 +size 21636 diff --git a/docs/images/tutorials/06-firstKey.png b/docs/images/tutorials/06-firstKey.png new file mode 100644 index 000000000..14600e3c8 --- /dev/null +++ b/docs/images/tutorials/06-firstKey.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ec28599720a324952caa090e52421ce701f09a39d9953ac1bd523e2f528c470 +size 16097 diff --git a/docs/images/tutorials/06-playing.png b/docs/images/tutorials/06-playing.png new file mode 100644 index 000000000..a6fd624d7 --- /dev/null +++ b/docs/images/tutorials/06-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:309c3445be3c5b06f909f5677ff477072f13cdd0d58b8d313a2f89cfaeda7175 +size 11652 diff --git a/docs/images/tutorials/06-rules.png b/docs/images/tutorials/06-rules.png new file mode 100644 index 000000000..e8db33eda --- /dev/null +++ b/docs/images/tutorials/06-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7830a60a15932805a1f49bc60195b48fc7b837445bc9aca4b51a56cf44b271a1 +size 24216 diff --git a/docs/images/tutorials/06-tracks.png b/docs/images/tutorials/06-tracks.png new file mode 100644 index 000000000..e9256b846 --- /dev/null +++ b/docs/images/tutorials/06-tracks.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08de1d007822ae9084cde3bec5d0c08e03c6de49bd25703f96e6f4df5a41ef74 +size 19079 diff --git a/docs/images/tutorials/06-types.png b/docs/images/tutorials/06-types.png new file mode 100644 index 000000000..ef87ab308 --- /dev/null +++ b/docs/images/tutorials/06-types.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c193b467e147ff4abf42d14acfef8691a0851a60492b084c053d142edc8e6804 +size 12488 diff --git a/docs/images/tutorials/07-dialogue.png b/docs/images/tutorials/07-dialogue.png new file mode 100644 index 000000000..408e9a9c4 --- /dev/null +++ b/docs/images/tutorials/07-dialogue.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:915555d16cb7e516158ac6443f1893470fcf17b1b9ac163cd3f8944115f32a5d +size 23619 diff --git a/docs/images/tutorials/07-hall.png b/docs/images/tutorials/07-hall.png new file mode 100644 index 000000000..2f767a7b6 --- /dev/null +++ b/docs/images/tutorials/07-hall.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c31d1732673a505541ac754986d150121c3ce33a14a0d6999ad77b732fe27b0e +size 19172 diff --git a/docs/images/tutorials/07-hotspots.png b/docs/images/tutorials/07-hotspots.png new file mode 100644 index 000000000..d245740d7 --- /dev/null +++ b/docs/images/tutorials/07-hotspots.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc5033bb6ba89287e2a064b772860fa8dfae9bab4b873edbcf4f680dace5743a +size 22025 diff --git a/docs/images/tutorials/07-playing.png b/docs/images/tutorials/07-playing.png new file mode 100644 index 000000000..1c0091819 --- /dev/null +++ b/docs/images/tutorials/07-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f86151f4a19647e47a24125093a269c1cb35b9b30178b40fe1296d8d8de0668d +size 12644 diff --git a/docs/images/tutorials/07-verbRules.png b/docs/images/tutorials/07-verbRules.png new file mode 100644 index 000000000..79dc52d23 --- /dev/null +++ b/docs/images/tutorials/07-verbRules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc7d958be89ec657c6faf42c5b53cc05eea807e961a66e3480d131903d47f422 +size 25808 diff --git a/docs/images/tutorials/07-walkArea.png b/docs/images/tutorials/07-walkArea.png new file mode 100644 index 000000000..41e703522 --- /dev/null +++ b/docs/images/tutorials/07-walkArea.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d65271b1c29a5b1a2da7bca06b0336f2ed8f5bb86e8f470f75dfe259d03ec47 +size 20184 diff --git a/docs/images/tutorials/08-hero.png b/docs/images/tutorials/08-hero.png new file mode 100644 index 000000000..15a76f16d --- /dev/null +++ b/docs/images/tutorials/08-hero.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:986a96057ba84f22281d3ccc171b421800bb957f122755cdb56719486cfe2a3c +size 29331 diff --git a/docs/images/tutorials/08-meshes.png b/docs/images/tutorials/08-meshes.png new file mode 100644 index 000000000..0d3fb6e7e --- /dev/null +++ b/docs/images/tutorials/08-meshes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ccba18dd3f0765563b49e46312bb9f46c62f1464cd4c0ce95857bdf5327d8ff +size 30334 diff --git a/docs/images/tutorials/08-playing.png b/docs/images/tutorials/08-playing.png new file mode 100644 index 000000000..8a472e49d --- /dev/null +++ b/docs/images/tutorials/08-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a17ace2deb1ddb0f320b9ffa79c2f17e744651975cd0942549b1cc2036457621 +size 22681 diff --git a/docs/images/tutorials/08-rules.png b/docs/images/tutorials/08-rules.png new file mode 100644 index 000000000..4682288c1 --- /dev/null +++ b/docs/images/tutorials/08-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cf674b29abc1d1056d52b8ab41f2197d148b40331c3edacdf16d665f23aabdc +size 31335 diff --git a/docs/images/tutorials/08-scene.png b/docs/images/tutorials/08-scene.png new file mode 100644 index 000000000..dabcf2d4b --- /dev/null +++ b/docs/images/tutorials/08-scene.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e09a6c14ab9babdfe1effb437a29494416ea82b96c5daf4b5af6602b1d748dc +size 32808 diff --git a/docs/images/tutorials/08-viewport.png b/docs/images/tutorials/08-viewport.png new file mode 100644 index 000000000..7d5e48c2a --- /dev/null +++ b/docs/images/tutorials/08-viewport.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a67b57ee3821c1a02810eadfecfbc112171c4b31539b063145f150db12c1a7e1 +size 27881 diff --git a/docs/images/tutorials/09-hitRule.png b/docs/images/tutorials/09-hitRule.png new file mode 100644 index 000000000..e788c1b1e --- /dev/null +++ b/docs/images/tutorials/09-hitRule.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b092d640bac349b7faa191582123a3411f40e71c88c52f55d38dbfe04ccb4093 +size 39825 diff --git a/docs/images/tutorials/09-placed.png b/docs/images/tutorials/09-placed.png new file mode 100644 index 000000000..d13f8ee3e --- /dev/null +++ b/docs/images/tutorials/09-placed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b64b5c3063c58fbcb39ef57f88a4fe43c1a0e46acade411c2ff65dbd827d9e5c +size 31931 diff --git a/docs/images/tutorials/09-playing.png b/docs/images/tutorials/09-playing.png new file mode 100644 index 000000000..696ec8586 --- /dev/null +++ b/docs/images/tutorials/09-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dad7bf1d0f75a7e1ec049b59acb62ed73be7d87dd1a44e31f0aa32d0432abc0 +size 18131 diff --git a/docs/images/tutorials/09-rail.png b/docs/images/tutorials/09-rail.png new file mode 100644 index 000000000..11e52a83b --- /dev/null +++ b/docs/images/tutorials/09-rail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf6370f808983ca01e57c5902f29e4d3304d3c106b5977ec2f715dbf89d9c575 +size 31045 diff --git a/docs/images/tutorials/09-room.png b/docs/images/tutorials/09-room.png new file mode 100644 index 000000000..c2bcff697 --- /dev/null +++ b/docs/images/tutorials/09-room.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d98365c22d6c466b2ef755d3c0dbcbb9796eb27b7f76b6b75ce10a89032d6fd8 +size 30509 diff --git a/docs/images/tutorials/09-rules.png b/docs/images/tutorials/09-rules.png new file mode 100644 index 000000000..991bc561a --- /dev/null +++ b/docs/images/tutorials/09-rules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:99fb0221b45047385dc3d83c99fe951d59c2ebb5110c347d36dbbfda37c53df5 +size 42166 diff --git a/docs/images/tutorials/09-zombie.png b/docs/images/tutorials/09-zombie.png new file mode 100644 index 000000000..e698ef1a2 --- /dev/null +++ b/docs/images/tutorials/09-zombie.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc3d14d6f7072d6bd477ace84b7509c2d4294346b8e5a77bf8bef5bd66e779b6 +size 29458 diff --git a/docs/images/tutorials/10-arcadeRules.png b/docs/images/tutorials/10-arcadeRules.png new file mode 100644 index 000000000..9a7ba37e9 --- /dev/null +++ b/docs/images/tutorials/10-arcadeRules.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab03d1d0915333d23424fa8694da35e579caf0d6e5ed31f54e4cef36ccd9a9ba +size 19257 diff --git a/docs/images/tutorials/10-bezel.png b/docs/images/tutorials/10-bezel.png new file mode 100644 index 000000000..3284f7bf3 --- /dev/null +++ b/docs/images/tutorials/10-bezel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4380b3a5397761d3930323a9ced5c2f9e11b95368d15c8a502fe11cd99daefb7 +size 20793 diff --git a/docs/images/tutorials/10-playing.png b/docs/images/tutorials/10-playing.png new file mode 100644 index 000000000..9e7bd6431 --- /dev/null +++ b/docs/images/tutorials/10-playing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1da8eaaa842f7f3b25b8201a21ee18c56f5442de268c8bb665ab3b433f67521 +size 7444 diff --git a/docs/images/tutorials/10-released.png b/docs/images/tutorials/10-released.png new file mode 100644 index 000000000..27788fd27 --- /dev/null +++ b/docs/images/tutorials/10-released.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f3b9183e7e40777eb24935ccf908db229d5ff80b97b997087b83ea09061350e +size 19262 diff --git a/src/gui.cpp b/src/gui.cpp index 288aabadc..82e943e2e 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -727,6 +727,14 @@ bool guiListen(int32_t gui, int32_t document, const char *id, const char *event) } for (GuiListenerT *existing : _listeners) { if (!existing->dead && (existing->gui == gui) && (existing->document == document) && (existing->event == event) && (strcmp(existing->id, id) == 0)) { + // The element that carried this id may have been replaced since -- a list rebuilt from + // inner_rml makes new elements -- so the listener follows the id to the one there now. + if (existing->element != element) { + if (existing->element != nullptr) { + existing->element->RemoveEventListener(existing->event, existing); + } + element->AddEventListener(existing->event, existing); + } return true; } } diff --git a/src/singe.c b/src/singe.c index cde0d3aee..7c899447f 100644 --- a/src/singe.c +++ b/src/singe.c @@ -97,6 +97,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L); #define MUSIC_MAX_VOLUME 128 // musicSetVolume keeps Hypseus's own scale, which is twice the one above #define GAMES_DAT_LEVELS 4 // Directories, the script's and above, searched for its games.dat #define GUI_DRAW_MAX 16 // guiDraw calls honoured per frame +#define OVERLAY_NO_LAYER -1 // A GUI draw with nothing drawn over it #define GUI_VALUE_MAX 4096 // Longest value guiGetValue hands back #define GUI_HANDLERS "singeGuiHandlers" // Registry table of guiSetHandler functions #define MAX_MICE 4 @@ -521,14 +522,27 @@ typedef struct GuiDrawS { SDL_FRect dest; bool whole; bool screen; + int32_t layer; // The overlay layer drawn over this GUI, or OVERLAY_NO_LAYER } GuiDrawT; +// What a script draws on the overlay after a guiDraw goes on a layer of its own, composited over +// that GUI, so the order of the script's own drawing says what is under a document and what is +// over it: a HUD under the pointer, the pointer over the HUD. +typedef struct OverlayLayerS { + SDL_Surface *surface; + SDL_Texture *texture; + bool dirty; +} OverlayLayerT; + typedef struct GlobalS { MouseT mice[MAX_MICE]; lua_State *luaContext; SDL_Color colorForeground; SDL_Color colorBackground; - SDL_Surface *overlay; + SDL_Surface *overlay; // What overlay drawing lands on: the base, or the layer the last guiDraw opened + SDL_Surface *overlayBase; // The overlay itself, under every GUI + OverlayLayerT overlayLayers[GUI_DRAW_MAX]; // The layers this turn's guiDraw calls opened, in order + int32_t overlayLayerCount; SDL_Surface *overlayGrey; // The overlay in luma only, for overlaySetMonochrome SDL_Texture *overlayTexture; SDL_FRect focusArea; // vldpFocusArea's part of the disc frame, in disc pixels @@ -541,7 +555,8 @@ typedef struct GlobalS { GuiDrawT guiShown[GUI_DRAW_MAX]; // Last frame's, for pointing at them int32_t guiShownCount; int32_t guiHover; // The GUI under the pointer, GUI_NO_HANDLE for none - bool guiTextActive; // SDL text input is on for a focused text field + bool guiTextActive; // A GUI taking input has a text field focused + bool textInputOn; // SDL text input is running: for that field, or for a MODE_FULL script SDL_Window *window; SDL_Renderer *renderer; SDL_GPUDevice *device; @@ -664,6 +679,7 @@ typedef struct GlobalS { bool audioMuted; // --nosound, or --startsilent until the first input uint64_t idleClock; // Ticks of the last input, for --idleexit bool requestScreenShot; + char *screenshotName; // singeScreenshot's chosen file name, or NULL for the next singeNNN.png bool wasPlayingBeforePause; VideoT *videoList; SpriteT *spriteList; @@ -887,6 +903,9 @@ static void _overlayApplyOpacity(void); static void _overlayApplyScaleMode(void); static uint32_t _overlayColor(const SDL_Color *color); static SDL_Surface *_overlayGrey(void); +static void _overlayLayerDraw(int32_t index, const SDL_FRect *target); +static int32_t _overlayLayerOpen(void); +static void _overlayLayersFree(void); static void _overlayResize(int32_t width, int32_t height); static void _overlayTouched(void); static void _particleTexturesDestroy(ParticleTexturesT *cache); @@ -2593,7 +2612,9 @@ static void _deliverKey(bool down, int32_t keysym, int32_t scancode) { int32_t move = 0; int32_t index = 0; - if (_global.keyboardMode == KEYBOARD_FULL) { + // Full mode hands the keyboard over whole: every key as its keysym, and as a key. A pad or + // mouse button cannot be typed, so it still arrives as the switch it is mapped to. + if ((_global.keyboardMode == KEYBOARD_FULL) && (scancode < CODE_GAMEPAD_BASE)) { _heldListUpdate(_global.heldKeys, &_global.heldKeyCount, down, keysym, scancode); _callLua(down ? "onInputPressed" : "onInputReleased", "i", keysym); _callLua(down ? "onKeyPressed" : "onKeyReleased", "ii", keysym, scancode); @@ -2802,6 +2823,10 @@ static void _drawGuis(const SDL_FRect *target, bool screen) { dest.h = _global.guiDraws[x].dest.h * scaleY; } SDL_RenderTexture(_global.renderer, texture, NULL, &dest); + // What the script drew after this GUI goes over it. + if (_global.guiDraws[x].layer != OVERLAY_NO_LAYER) { + _overlayLayerDraw(_global.guiDraws[x].layer, target); + } } } @@ -3400,6 +3425,12 @@ static bool _guiKey(SDL_Keycode key, SDL_Keymod modifiers, bool down) { bool consumed = false; int32_t x; + // RmlUi takes TAB for moving focus between fields and never hands it back, so a game whose + // keys include TAB would lose it to a document that has no field to focus. It goes to the + // GUI only while a text field is being typed in. + if ((key == SDLK_TAB) && !_global.guiTextActive) { + return false; + } for (x = 0; x < GUI_MAX_CONTEXTS; x++) { if (guiValid(x) && guiGetInput(x) && guiKey(x, key, modifiers, down)) { consumed = true; @@ -3522,6 +3553,7 @@ static int32_t _guiQueueDraw(lua_State *L, const char *method, bool screen) { draw->dest.w = (top == 5) ? (float)_argNumber(L, method, 4) : (float)guiGetWidth(draw->gui); draw->dest.h = (top == 5) ? (float)_argNumber(L, method, 5) : (float)guiGetHeight(draw->gui); } + draw->layer = screen ? OVERLAY_NO_LAYER : _overlayLayerOpen(); _global.guiDrawCount++; _overlayTouched(); @@ -3582,18 +3614,23 @@ static bool _guiText(const char *text) { } -// SDL's text input runs exactly while a GUI taking input has a text field focused. +// SDL's text input runs while a GUI taking input has a text field focused, and while the script +// has the keyboard whole (MODE_FULL), when what the keys produce -- shifted, on whatever layout -- +// reaches it as onTextInput. static void _guiTextInput(void) { + bool field = false; bool wants = false; int32_t x; for (x = 0; x < GUI_MAX_CONTEXTS; x++) { if (guiValid(x) && guiGetInput(x) && guiWantsText(x)) { - wants = true; + field = true; } } - if (wants != _global.guiTextActive) { - _global.guiTextActive = wants; + _global.guiTextActive = field; + wants = field || (_global.keyboardMode == KEYBOARD_FULL); + if (wants != _global.textInputOn) { + _global.textInputOn = wants; if (wants) { SDL_StartTextInput(_global.window); } else { @@ -5174,15 +5211,15 @@ static SDL_Surface *_overlayGrey(void) { uint8_t grey = 0; if (_global.overlayGrey == NULL) { - _global.overlayGrey = SDL_CreateSurface(_global.overlay->w, _global.overlay->h, _global.overlay->format); + _global.overlayGrey = SDL_CreateSurface(_global.overlayBase->w, _global.overlayBase->h, _global.overlayBase->format); if (_global.overlayGrey == NULL) { utilDie("%s", SDL_GetError()); } } - for (y = 0; y < _global.overlay->h; y++) { - in = (const uint8_t *)_global.overlay->pixels + (size_t)y * (size_t)_global.overlay->pitch; + for (y = 0; y < _global.overlayBase->h; y++) { + in = (const uint8_t *)_global.overlayBase->pixels + (size_t)y * (size_t)_global.overlayBase->pitch; out = (uint8_t *)_global.overlayGrey->pixels + (size_t)y * (size_t)_global.overlayGrey->pitch; - for (x = 0; x < _global.overlay->w; x++) { + for (x = 0; x < _global.overlayBase->w; x++) { grey = (uint8_t)((LUMA_WEIGHT_RED * in[BGRA_RED] + LUMA_WEIGHT_GREEN * in[BGRA_GREEN] + LUMA_WEIGHT_BLUE * in[BGRA_BLUE]) >> LUMA_WEIGHT_SHIFT); out[BGRA_BLUE] = grey; out[BGRA_GREEN] = grey; @@ -5197,15 +5234,72 @@ static SDL_Surface *_overlayGrey(void) { } +// Draws one layer over its GUI, uploading it first when something was drawn on it since. +static void _overlayLayerDraw(int32_t index, const SDL_FRect *target) { + OverlayLayerT *layer = &_global.overlayLayers[index]; + + if (layer->dirty) { + SDL_UpdateTexture(layer->texture, NULL, layer->surface->pixels, layer->surface->pitch); + layer->dirty = false; + } + SDL_RenderTexture(_global.renderer, layer->texture, NULL, target); +} + + +// Opens the next layer: clear glass the size of the overlay, made the first time it is needed, +// which overlay drawing lands on from now until the script's next turn. Answers its index, or +// OVERLAY_NO_LAYER when every layer is in use, in which case drawing stays where it was. +static int32_t _overlayLayerOpen(void) { + OverlayLayerT *layer = NULL; + int32_t index = _global.overlayLayerCount; + + if (index >= GUI_DRAW_MAX) { + return OVERLAY_NO_LAYER; + } + layer = &_global.overlayLayers[index]; + if (layer->surface == NULL) { + layer->surface = SDL_CreateSurface(_global.overlayBase->w, _global.overlayBase->h, SDL_PIXELFORMAT_BGRA32); + layer->texture = SDL_CreateTexture(_global.renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, _global.overlayBase->w, _global.overlayBase->h); + if ((layer->surface == NULL) || (layer->texture == NULL)) { + utilDie("%s", SDL_GetError()); + } + SDL_SetSurfaceBlendMode(layer->surface, SDL_BLENDMODE_BLEND); + SDL_SetTextureBlendMode(layer->texture, SDL_BLENDMODE_BLEND); + } + SDL_FillSurfaceRect(layer->surface, NULL, 0); + layer->dirty = true; + _global.overlay = layer->surface; + _global.overlayLayerCount = index + 1; + + return index; +} + + +// Drops every layer: the overlay changed size, or the engine is closing. +static void _overlayLayersFree(void) { + int32_t x = 0; + + for (x = 0; x < GUI_DRAW_MAX; x++) { + SDL_DestroySurface(_global.overlayLayers[x].surface); + SDL_DestroyTexture(_global.overlayLayers[x].texture); + _global.overlayLayers[x].surface = NULL; + _global.overlayLayers[x].texture = NULL; + } + _global.overlayLayerCount = 0; +} + + // Replaces the overlay surface and texture (and the scene's targets) at a new size; its contents are lost. static void _overlayResize(int32_t width, int32_t height) { SDL_DestroySurface(_global.overlayGrey); _global.overlayGrey = NULL; - SDL_DestroySurface(_global.overlay); - _global.overlay = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_BGRA32); - if (_global.overlay == NULL) { + _overlayLayersFree(); + SDL_DestroySurface(_global.overlayBase); + _global.overlayBase = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_BGRA32); + if (_global.overlayBase == NULL) { utilDie("%s", SDL_GetError()); } + _global.overlay = _global.overlayBase; SDL_SetSurfaceBlendMode(_global.overlay, SDL_BLENDMODE_BLEND); SDL_DestroyTexture(_global.overlayTexture); _global.overlayTexture = SDL_CreateTexture(_global.renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, width, height); @@ -5222,9 +5316,14 @@ static void _overlayResize(int32_t width, int32_t height) { } -// Every overlay drawing call ends here so the texture is only re-uploaded when needed. +// Every overlay drawing call ends here so a texture is only re-uploaded when needed: the base's, +// or the open layer's. static void _overlayTouched(void) { - _global.overlayDirty = true; + if (_global.overlay == _global.overlayBase) { + _global.overlayDirty = true; + } else { + _global.overlayLayers[_global.overlayLayerCount - 1].dirty = true; + } } @@ -6367,8 +6466,9 @@ static void _resetScriptState(void) { _scorePanelReset(); _subtitleReset(); _scheduleReset(_global.luaContext); - if (_global.guiTextActive) { + if (_global.textInputOn) { SDL_StopTextInput(_global.window); + _global.textInputOn = false; _global.guiTextActive = false; } for (x = 0; x < EFFECT_TRACKS; x++) { @@ -7959,18 +8059,25 @@ static void _takeScreenshot(void) { SDL_Surface *rgb = NULL; SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED; - // Each script starts scanning at zero; later shots resume past the last one saved. - for (x = _global.nextScreenshot; x < SCREENSHOT_MAX; x++) { - free(filename); - filename = utilCreateString("%ssinge%03d.png", _global.conf->dataDir, x); - if (!utilFileExists(filename)) { - break; + if (_global.screenshotName != NULL) { + // A shot the script named goes where it said, over whatever was there. + filename = utilCreateString("%s%s", _global.conf->dataDir, _global.screenshotName); + free(_global.screenshotName); + _global.screenshotName = NULL; + } else { + // Each script starts scanning at zero; later shots resume past the last one saved. + for (x = _global.nextScreenshot; x < SCREENSHOT_MAX; x++) { + free(filename); + filename = utilCreateString("%ssinge%03d.png", _global.conf->dataDir, x); + if (!utilFileExists(filename)) { + break; + } } + if (x >= SCREENSHOT_MAX) { + utilDie("Seriously? You have %d screenshots in this folder? Remove some.", SCREENSHOT_MAX); + } + _global.nextScreenshot = x + 1; } - if (x >= SCREENSHOT_MAX) { - utilDie("Seriously? You have %d screenshots in this folder? Remove some.", SCREENSHOT_MAX); - } - _global.nextScreenshot = x + 1; // Read the whole window, letterbox included: with a logical size set, a NULL rect would // only cover the scaled viewport, so switch it off around the read. @@ -10213,6 +10320,7 @@ static int32_t apiKeyboardSetMode(lua_State *L) { _luaDie(L, "keyboardSetMode", "Unknown keyboard mode: %d", mode); } _global.keyboardMode = (KeyboardModeE)mode; + _guiTextInput(); _luaTrace(L, "keyboardSetMode", "%d", _global.keyboardMode); return 0; @@ -13474,8 +13582,22 @@ static int32_t apiSingeReload(lua_State *L) { // singeScreenshot() Saved after the next frame is drawn. +// singeScreenshot([name]) The next singeNNN.png, or a file of the given name (.png added when it +// has no extension), in the data directory. static int32_t apiSingeScreenshot(lua_State *L) { - _luaTrace(L, "singeScreenshot", "Screenshot requested."); + const char *name = NULL; + + _argCheck(L, "singeScreenshot", 0, 1); + free(_global.screenshotName); + _global.screenshotName = NULL; + if (lua_gettop(L) >= 1) { + name = _argString(L, "singeScreenshot", 1); + if ((strchr(name, '/') != NULL) || (strchr(name, '\\') != NULL) || (name[0] == '\0')) { + _luaDie(L, "singeScreenshot", "A screenshot name is a file name, not a path: %s", name); + } + _global.screenshotName = (strrchr(name, '.') != NULL) ? utilCreateString("%s", name) : utilCreateString("%s.png", name); + } + _luaTrace(L, "singeScreenshot", "Screenshot requested: %s", (_global.screenshotName != NULL) ? _global.screenshotName : "next"); _global.requestScreenShot = true; _global.refreshDisplay = true; @@ -16499,7 +16621,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co break; case SDL_EVENT_TEXT_INPUT: - _guiText(event.text.text); + // A focused text field takes it; otherwise a script with the keyboard whole does. + if (!_guiText(event.text.text) && (_global.keyboardMode == KEYBOARD_FULL) && !_global.frozen) { + _callLua("onTextInput", "s", event.text.text); + } break; case SDL_EVENT_WINDOW_FOCUS_GAINED: @@ -16712,8 +16837,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // the 2D particles only on the frames the script happened to run on, which is a menu that // blinks over a grid that does not. if (!_global.frozen && (utilTicks() > frameClock)) { - intReturn = OVERLAY_NOT_UPDATED; - _global.guiDrawCount = 0; + intReturn = OVERLAY_NOT_UPDATED; + _global.guiDrawCount = 0; + _global.overlayLayerCount = 0; + _global.overlay = _global.overlayBase; particlesClearQueue2D(); _callLua("onOverlayUpdate", ">i", &intReturn); if (intReturn == OVERLAY_UPDATED) { @@ -16773,7 +16900,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // 2D particles beneath the overlay, then the overlay, then the ones above it _drawParticles2D(PARTICLE_UNDER, &_global.videoRect); if (_global.overlayDirty) { - overlayShown = _global.overlayMonochrome ? _overlayGrey() : _global.overlay; + overlayShown = _global.overlayMonochrome ? _overlayGrey() : _global.overlayBase; SDL_UpdateTexture(_global.overlayTexture, NULL, overlayShown->pixels, overlayShown->pitch); _global.overlayDirty = false; } @@ -16873,7 +17000,8 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co SDL_DestroyTexture(_global.pauseTexture); _subsystemsQuit(); SDL_DestroyTexture(_global.overlayTexture); - SDL_DestroySurface(_global.overlay); + _overlayLayersFree(); + SDL_DestroySurface(_global.overlayBase); SDL_DestroySurface(_global.overlayGrey); _progTrace("Destroying console font"); SDL_DestroySurface(_global.consoleFontSurface); diff --git a/testScripts/author/adventure2d.game b/testScripts/author/adventure2d.game index 26079decc..57a938854 100644 --- a/testScripts/author/adventure2d.game +++ b/testScripts/author/adventure2d.game @@ -13,7 +13,7 @@ return { vars = { score = 0, allowed = false }, verbs = { "walk", "look", "take", "use", "open", "talk" }, - kinds = { + types = { backdrop = { look = { kind = "box", w = 720, h = 480, r = 40, g = 45, b = 60 } }, floor = { look = { kind = "box", w = 720, h = 200, r = 70, g = 60, b = 50, anchor = "feet" } }, hero = { look = { kind = "box", w = 30, h = 70, r = 230, g = 90, b = 170, anchor = "feet" }, @@ -35,23 +35,23 @@ return { scaleBy = { { y = 300, scale = 0.6 }, { y = 460, scale = 1.0 } }, walk = { { 120, 300, 640, 300, 700, 460, 60, 460 } }, entities = { - { kind = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 }, - { kind = "floor", id = "floor", x = 360, y = 480, z = 0 }, - { kind = "door", id = "door", x = 600, y = 300, z = 0 }, - { kind = "guard", id = "guard", x = 540, y = 340, z = 0 }, - { kind = "pillar", id = "pillar", x = 360, y = 420, z = 0 }, - { kind = "key", id = "key", x = 200, y = 380, z = 0 }, - { kind = "hero", id = "hero", x = 150, y = 440, z = 0 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 }, + { type = "floor", id = "floor", x = 360, y = 480, z = 0 }, + { type = "door", id = "door", x = 600, y = 300, z = 0 }, + { type = "guard", id = "guard", x = 540, y = 340, z = 0 }, + { type = "pillar", id = "pillar", x = 360, y = 420, z = 0 }, + { type = "key", id = "key", x = 200, y = 380, z = 0 }, + { type = "hero", id = "hero", x = 150, y = 440, z = 0 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } }, { name = "yard", depthSort = true, walk = { { 40, 320, 680, 320, 680, 460, 40, 460 } }, entities = { - { kind = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 }, - { kind = "door", id = "door", x = 100, y = 300, z = 0 }, - { kind = "marker", id = "arrival", x = 160, y = 420, z = 0 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 }, + { type = "door", id = "door", x = 100, y = 300, z = 0 }, + { type = "marker", id = "arrival", x = 160, y = 420, z = 0 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, diff --git a/testScripts/author/adventure3d.game b/testScripts/author/adventure3d.game index ddb4da9a6..d4cad6ffe 100644 --- a/testScripts/author/adventure3d.game +++ b/testScripts/author/adventure3d.game @@ -1,6 +1,6 @@ -- Genre two of milestone 3: the same adventure in a modelled room from a fixed camera -- Grim -- Fandango's shape. The rules, the dialogue, the verbs, and the parser are the 2D game's own, --- taken from its description; only the kinds and the rooms are new. That the rules do not +-- taken from its description; only the types and the rooms are new. That the rules do not -- change between a painting and a scene is the point. local base = dofile("testScripts/author/adventure2d.game") @@ -13,7 +13,7 @@ return { dialogues = base.dialogues, rules = base.rules, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 16, h = 0.4, d = 12, r = 80, g = 70, b = 60 }, behaviours = { { kind = "solid" } } }, hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.8, d = 0.6 }, behaviours = { { kind = "walker", speed = 2.5, radius = 0.3 }, { kind = "animator", idle = "Survey", walk = "Walk" } } }, @@ -32,22 +32,22 @@ return { { name = "hall", navFrom = { "floor" }, entities = { - { kind = "floor", id = "floor", x = 0, y = -0.2, z = -1 }, - { kind = "camera", id = "camera", x = 0, y = 6, z = 8 }, - { kind = "door", id = "door", x = 5, y = 1.2, z = -4.2 }, - { kind = "guard", id = "guard", x = 4, y = 0.9, z = -3 }, - { kind = "key", id = "key", x = -3, y = 0.15, z = 0 }, - { kind = "hero", id = "hero", x = -4, y = 0, z = 3 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.2, z = -1 }, + { type = "camera", id = "camera", x = 0, y = 6, z = 8 }, + { type = "door", id = "door", x = 5, y = 1.2, z = -4.2 }, + { type = "guard", id = "guard", x = 4, y = 0.9, z = -3 }, + { type = "key", id = "key", x = -3, y = 0.15, z = 0 }, + { type = "hero", id = "hero", x = -4, y = 0, z = 3 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } }, { name = "yard", navFrom = { "floor" }, entities = { - { kind = "floor", id = "floor", x = 0, y = -0.2, z = -1 }, - { kind = "camera", id = "camera", x = 0, y = 6, z = 8 }, - { kind = "door", id = "door", x = -5, y = 1.2, z = -4.2 }, - { kind = "marker", id = "arrival", x = -4, y = 0, z = -2 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.2, z = -1 }, + { type = "camera", id = "camera", x = 0, y = 6, z = 8 }, + { type = "door", id = "door", x = -5, y = 1.2, z = -4.2 }, + { type = "marker", id = "arrival", x = -4, y = 0, z = -2 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } } } diff --git a/testScripts/author/bowling.game b/testScripts/author/bowling.game index 2da9513b0..cd33e8c68 100644 --- a/testScripts/author/bowling.game +++ b/testScripts/author/bowling.game @@ -6,7 +6,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 100, g = 100, b = 110 }, fov = 50 }, { kind = "overlay" } }, vars = { score = 0, down = 0, thrown = false }, - kinds = { + types = { lane = { look = { kind = "mesh", shape = "box", w = 3, h = 0.3, d = 24, r = 180, g = 150, b = 100 }, behaviours = { { kind = "solid" } } }, ball = { look = { kind = "mesh", shape = "sphere", w = 0.44, r = 40, g = 40, b = 60 }, behaviours = { { kind = "body", shape = "sphere", mass = 7, friction = 0.2, bounce = 0.1 } } }, pin = { look = { kind = "mesh", shape = "cylinder", w = 0.24, h = 0.8, r = 240, g = 240, b = 240 }, vars = { counted = false }, @@ -19,17 +19,17 @@ return { rooms = { { name = "alley", entities = { - { kind = "lane", id = "lane", x = 0, y = -0.15, z = -8 }, - { kind = "ball", id = "ball", x = 0, y = 0.3, z = 2 }, - { kind = "pin", id = "pin1", x = 0, y = 0.4, z = -16 }, - { kind = "pin", id = "pin2", x = -0.4, y = 0.4, z = -16.7 }, - { kind = "pin", id = "pin3", x = 0.4, y = 0.4, z = -16.7 }, - { kind = "pin", id = "pin4", x = -0.8, y = 0.4, z = -17.4 }, - { kind = "pin", id = "pin5", x = 0, y = 0.4, z = -17.4 }, - { kind = "pin", id = "pin6", x = 0.8, y = 0.4, z = -17.4 }, - { kind = "gutter", id = "gutter", x = 0, y = 0, z = -21 }, - { kind = "camera", id = "camera", x = 0, y = 2.5, z = 7 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "lane", id = "lane", x = 0, y = -0.15, z = -8 }, + { type = "ball", id = "ball", x = 0, y = 0.3, z = 2 }, + { type = "pin", id = "pin1", x = 0, y = 0.4, z = -16 }, + { type = "pin", id = "pin2", x = -0.4, y = 0.4, z = -16.7 }, + { type = "pin", id = "pin3", x = 0.4, y = 0.4, z = -16.7 }, + { type = "pin", id = "pin4", x = -0.8, y = 0.4, z = -17.4 }, + { type = "pin", id = "pin5", x = 0, y = 0.4, z = -17.4 }, + { type = "pin", id = "pin6", x = 0.8, y = 0.4, z = -17.4 }, + { type = "gutter", id = "gutter", x = 0, y = 0, z = -21 }, + { type = "camera", id = "camera", x = 0, y = 2.5, z = 7 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, diff --git a/testScripts/author/breakout.game b/testScripts/author/breakout.game index 33e33afd4..088c3c403 100644 --- a/testScripts/author/breakout.game +++ b/testScripts/author/breakout.game @@ -6,7 +6,7 @@ return { layers = { { kind = "world2d", gravity = 0 } }, vars = { score = 0, lives = 3, broken = 0 }, - kinds = { + types = { wall = { look = { kind = "box", w = 20, h = 480, r = 90, g = 90, b = 110 }, behaviours = { { kind = "solid" } } }, roof = { look = { kind = "box", w = 720, h = 20, r = 90, g = 90, b = 110 }, behaviours = { { kind = "solid" } } }, paddle = { look = { kind = "box", w = 100, h = 14, r = 230, g = 230, b = 240 }, @@ -20,17 +20,17 @@ return { rooms = { { name = "court", entities = { - { kind = "wall", id = "wallL", x = 10, y = 240 }, - { kind = "wall", id = "wallR", x = 710, y = 240 }, - { kind = "roof", id = "roof", x = 360, y = 10 }, - { kind = "brick", id = "brick1", x = 200, y = 100 }, - { kind = "brick", id = "brick2", x = 280, y = 100 }, - { kind = "brick", id = "brick3", x = 360, y = 100 }, - { kind = "brick", id = "brick4", x = 440, y = 100 }, - { kind = "brick", id = "brick5", x = 520, y = 100 }, - { kind = "paddle", id = "paddle", x = 360, y = 440 }, - { kind = "ball", id = "ball", x = 360, y = 380 }, - { kind = "readout", id = "readout", x = 30, y = 30 } + { type = "wall", id = "wallL", x = 10, y = 240 }, + { type = "wall", id = "wallR", x = 710, y = 240 }, + { type = "roof", id = "roof", x = 360, y = 10 }, + { type = "brick", id = "brick1", x = 200, y = 100 }, + { type = "brick", id = "brick2", x = 280, y = 100 }, + { type = "brick", id = "brick3", x = 360, y = 100 }, + { type = "brick", id = "brick4", x = 440, y = 100 }, + { type = "brick", id = "brick5", x = 520, y = 100 }, + { type = "paddle", id = "paddle", x = 360, y = 440 }, + { type = "ball", id = "ball", x = 360, y = 380 }, + { type = "readout", id = "readout", x = 30, y = 30 } } } }, diff --git a/testScripts/author/fmv.game b/testScripts/author/fmv.game index f9aa594a9..0c0f67d3f 100644 --- a/testScripts/author/fmv.game +++ b/testScripts/author/fmv.game @@ -7,7 +7,7 @@ return { layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" }, { kind = "bezel" } }, vars = { score = 0, lives = 3, credits = 0, taken = 0, missed = 0 }, - kinds = { + types = { dirk = { look = { kind = "none" }, behaviours = { { kind = "branching", track = "moves" } } }, readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }, prompt = { look = { kind = "text", text = "", r = 255, g = 220, b = 80 } } @@ -16,9 +16,9 @@ return { rooms = { { name = "cavern", entities = { - { kind = "dirk", id = "dirk", x = 0, y = 0 }, - { kind = "prompt", id = "prompt", x = 250, y = 60 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "dirk", id = "dirk", x = 0, y = 0 }, + { type = "prompt", id = "prompt", x = 250, y = 60 }, + { type = "readout", id = "readout", x = 12, y = 12 } }, tracks = { { name = "moves", key = "frame", diff --git a/testScripts/author/fps.game b/testScripts/author/fps.game index 49c5c5c93..bc95000b9 100644 --- a/testScripts/author/fps.game +++ b/testScripts/author/fps.game @@ -7,7 +7,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 60, g = 60, b = 80 }, fog = { r = 10, g = 10, b = 16, near = 20, far = 50 }, fov = 70 }, { kind = "overlay" } }, vars = { score = 0, health = 5, bites = 0 }, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 70, g = 70, b = 80 }, behaviours = { { kind = "solid" } } }, wall = { look = { kind = "mesh", shape = "box", w = 1, h = 3, d = 40, r = 110, g = 60, b = 60 }, behaviours = { { kind = "solid" } } }, hero = { look = { kind = "none", w = 0.6, h = 1.7, d = 0.6 }, @@ -30,37 +30,37 @@ return { { name = "arena", navFrom = { "floor" }, entities = { - { kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { kind = "wall", id = "wallL", x = -12, y = 1.5, z = 0 }, - { kind = "wall", id = "wallR", x = 12, y = 1.5, z = 0 }, - { kind = "hero", id = "hero", x = 0, y = 0.2, z = 8 }, - { kind = "eyes", id = "eyes", x = 0, y = 1.7, z = 8 }, - { kind = "gun", id = "gun1", x = 0, y = 0, z = 0 }, - { kind = "hive", id = "hive", x = 0, y = 0, z = -14 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, + { type = "wall", id = "wallL", x = -12, y = 1.5, z = 0 }, + { type = "wall", id = "wallR", x = 12, y = 1.5, z = 0 }, + { type = "hero", id = "hero", x = 0, y = 0.2, z = 8 }, + { type = "eyes", id = "eyes", x = 0, y = 1.7, z = 8 }, + { type = "gun", id = "gun1", x = 0, y = 0, z = 0 }, + { type = "hive", id = "hive", x = 0, y = 0, z = -14 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, rules = { { note = "A zombie walks from the moment it is made", - on = "spawn", kind = "zombie", + on = "spawn", type = "zombie", act = { { "setState", entity = "self", state = "walk" } } }, { note = "A shot lands", - on = "hit", kind = "zombie", + on = "hit", type = "zombie", act = { { "damage", entity = "self", amount = 1 }, { "addScore", amount = 50 } } }, { note = "A headshot", - on = "hit", kind = "zombie", + on = "hit", type = "zombie", when = { { "hitPart", part = "head" } }, act = { { "damage", entity = "self", amount = 2 }, { "addScore", amount = 150 } } }, { note = "It reaches the hero and rears up", - on = "arrived", kind = "zombie", + on = "arrived", type = "zombie", act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.2 } } }, { note = "The bite lands unless it was killed", - on = "timer", name = "bite", kind = "zombie", + on = "timer", name = "bite", type = "zombie", when = { { "inState", entity = "self", state = "attack" } }, act = { { "addVar", name = "health", amount = -1 }, { "addVar", name = "bites", amount = 1 }, { "flash", r = 255, g = 40, b = 40, seconds = 0.2 }, { "timerStart", entity = "self", name = "bite", after = 1.2 } } }, diff --git a/testScripts/author/gunvideo.game b/testScripts/author/gunvideo.game index acbfd8743..7adc2b72d 100644 --- a/testScripts/author/gunvideo.game +++ b/testScripts/author/gunvideo.game @@ -8,7 +8,7 @@ return { layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, vars = { score = 0, misses = 0 }, - kinds = { + types = { gun = { look = { kind = "none" }, vars = { ammo = 3 }, behaviours = { { kind = "gun", player = 1, ammo = 3, reload = "offscreen" } } }, @@ -23,10 +23,10 @@ return { rooms = { { name = "street", entities = { - { kind = "gun", id = "gun1", x = 0, y = 0 }, - { kind = "bandit", id = "bandit", x = 0, y = 0 }, - { kind = "hostage", id = "hostage", x = 0, y = 0 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "gun", id = "gun1", x = 0, y = 0 }, + { type = "bandit", id = "bandit", x = 0, y = 0 }, + { type = "hostage", id = "hostage", x = 0, y = 0 }, + { type = "readout", id = "readout", x = 12, y = 12 } }, tracks = { { name = "bandit", key = "frame", boxes = { { at = 120, x = 100, y = 200, w = 60, h = 100 }, { at = 220, x = 400, y = 200, w = 60, h = 100 } } }, @@ -36,12 +36,12 @@ return { rules = { { note = "The bandit is shot", - on = "hit", kind = "bandit", + on = "hit", type = "bandit", act = { { "addScore", amount = 100 }, { "damage", entity = "self", amount = 1 } } }, { note = "The hostage is shot", - on = "hit", kind = "hostage", + on = "hit", type = "hostage", act = { { "addScore", amount = -200 }, { "flash", r = 255, g = 40, b = 40, seconds = 0.3 } } }, diff --git a/testScripts/author/maze.game b/testScripts/author/maze.game index 622dc7366..70dcd62f0 100644 --- a/testScripts/author/maze.game +++ b/testScripts/author/maze.game @@ -6,7 +6,7 @@ return { layers = { { kind = "overlay" } }, vars = { score = 0, eaten = 0 }, - kinds = { + types = { walls = { look = { kind = "grid", file = "testScripts/checker.png", tile = 32, columns = 2, map = "1 1 1 1 1 1 1 1 1 1 1; 1 0 0 0 0 0 0 0 0 0 1; 1 0 1 1 1 0 1 1 1 0 1; 1 0 0 0 0 0 0 0 0 0 1; 1 1 1 1 1 1 1 1 1 1 1" } }, hero = { look = { kind = "box", w = 24, h = 24, r = 255, g = 230, b = 60 }, behaviours = { { kind = "walker", speed = 140, radius = 6 } } }, ghost = { look = { kind = "box", w = 24, h = 24, r = 90, g = 120, b = 255 }, behaviours = { { kind = "walker", speed = 90, radius = 6, follow = "hero", every = 0.3, stopAt = 10 } } }, @@ -18,13 +18,13 @@ return { { name = "maze", walk = { { 40, 140, 360, 140, 360, 220, 40, 220 }, { 40, 220, 360, 220, 360, 300, 40, 300 } }, entities = { - { kind = "walls", id = "walls", x = 8, y = 100 }, - { kind = "dot", id = "dot1", x = 150, y = 180 }, - { kind = "dot", id = "dot2", x = 250, y = 180 }, - { kind = "dot", id = "dot3", x = 250, y = 260 }, - { kind = "hero", id = "hero", x = 60, y = 180 }, - { kind = "ghost", id = "ghost", x = 340, y = 260 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "walls", id = "walls", x = 8, y = 100 }, + { type = "dot", id = "dot1", x = 150, y = 180 }, + { type = "dot", id = "dot2", x = 250, y = 180 }, + { type = "dot", id = "dot3", x = 250, y = 260 }, + { type = "hero", id = "hero", x = 60, y = 180 }, + { type = "ghost", id = "ghost", x = 340, y = 260 }, + { type = "readout", id = "readout", x = 12, y = 12 } } } }, diff --git a/testScripts/author/painted.game b/testScripts/author/painted.game index d02da4db2..9fdf561b6 100644 --- a/testScripts/author/painted.game +++ b/testScripts/author/painted.game @@ -7,7 +7,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 120, g = 120, b = 130 }, sun = false, fov = 50 }, { kind = "overlay" } }, vars = { score = 0 }, - kinds = { + types = { painting = { look = { kind = "mesh", shape = "box", w = 16, h = 9, d = 0.2, r = 255, g = 255, b = 255, texture = "testScripts/hills.png", unlit = true } }, pillar = { look = { kind = "mesh", shape = "box", w = 1.6, h = 5, d = 1.6, occluder = true } }, floor = { look = { kind = "mesh", shape = "box", w = 20, h = 0.2, d = 12, r = 90, g = 90, b = 90 }, behaviours = { { kind = "solid" } } }, @@ -21,19 +21,19 @@ return { rooms = { { name = "hall", entities = { - { kind = "painting", id = "painting", x = 0, y = 3, z = -9 }, - { kind = "floor", id = "floor", x = 0, y = -0.1, z = -4 }, - { kind = "pillar", id = "pillar", x = 0, y = 2.5, z = -4 }, - { kind = "hero", id = "hero", x = -5, y = 0, z = -6 }, - { kind = "marker", id = "far", x = 5, y = 0, z = -6, ry = 90, scale = 2 }, - { kind = "camera", id = "camera", x = 0, y = 2.2, z = 5 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "painting", id = "painting", x = 0, y = 3, z = -9 }, + { type = "floor", id = "floor", x = 0, y = -0.1, z = -4 }, + { type = "pillar", id = "pillar", x = 0, y = 2.5, z = -4 }, + { type = "hero", id = "hero", x = -5, y = 0, z = -6 }, + { type = "marker", id = "far", x = 5, y = 0, z = -6, ry = 90, scale = 2 }, + { type = "camera", id = "camera", x = 0, y = 2.2, z = 5 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, rules = { { note = "Across the room", - on = "arrived", kind = "hero", + on = "arrived", type = "hero", act = { { "setVar", name = "across", value = true } } }, { note = "The readout, every frame", diff --git a/testScripts/author/platformer.game b/testScripts/author/platformer.game index de517718c..8287e34ad 100644 --- a/testScripts/author/platformer.game +++ b/testScripts/author/platformer.game @@ -9,7 +9,7 @@ return { layers = { { kind = "world2d", gravity = 1500 } }, vars = { score = 0 }, - kinds = { + types = { ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, ledge = { look = { kind = "box", w = 180, h = 20, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, prize = { look = { kind = "box", w = 18, h = 18, r = 255, g = 200, b = 60 }, behaviours = { { kind = "drift", vx = 0, vy = 0 } } }, @@ -20,11 +20,11 @@ return { rooms = { { name = "field", entities = { - { kind = "ground", id = "ground", x = 360, y = 440 }, - { kind = "ledge", id = "ledge", x = 520, y = 330 }, - { kind = "prize", id = "prize", x = 520, y = 300 }, - { kind = "hero", id = "hero", x = 120, y = 380 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "ground", id = "ground", x = 360, y = 440 }, + { type = "ledge", id = "ledge", x = 520, y = 330 }, + { type = "prize", id = "prize", x = 520, y = 300 }, + { type = "hero", id = "hero", x = 120, y = 380 }, + { type = "readout", id = "readout", x = 12, y = 12 } } } }, diff --git a/testScripts/author/platformer3d.game b/testScripts/author/platformer3d.game index 78977c548..409abc909 100644 --- a/testScripts/author/platformer3d.game +++ b/testScripts/author/platformer3d.game @@ -7,7 +7,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 55 }, { kind = "overlay" } }, vars = { score = 0 }, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 30, h = 0.4, d = 12, r = 90, g = 110, b = 90 }, behaviours = { { kind = "solid" } } }, ledge = { look = { kind = "mesh", shape = "box", w = 4, h = 0.4, d = 4, r = 110, g = 90, b = 70 }, behaviours = { { kind = "solid" } } }, prize = { look = { kind = "mesh", shape = "sphere", w = 0.6, r = 255, g = 200, b = 60 }, behaviours = { { kind = "trigger" } } }, @@ -22,12 +22,12 @@ return { rooms = { { name = "field", entities = { - { kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { kind = "ledge", id = "ledge", x = 6, y = 1.2, z = 0 }, - { kind = "prize", id = "prize", x = 6, y = 2.0, z = 0 }, - { kind = "hero", id = "hero", x = -4, y = 0.5, z = 0 }, - { kind = "camera", id = "camera", x = -4, y = 3, z = 7 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, + { type = "ledge", id = "ledge", x = 6, y = 1.2, z = 0 }, + { type = "prize", id = "prize", x = 6, y = 2.0, z = 0 }, + { type = "hero", id = "hero", x = -4, y = 0.5, z = 0 }, + { type = "camera", id = "camera", x = -4, y = 3, z = 7 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, diff --git a/testScripts/author/pool.game b/testScripts/author/pool.game index 9ea42f694..c29c5ba9e 100644 --- a/testScripts/author/pool.game +++ b/testScripts/author/pool.game @@ -6,7 +6,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 90, g = 100, b = 120 }, fov = 55 }, { kind = "overlay" } }, vars = { score = 0 }, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 90, g = 90, b = 80 }, behaviours = { { kind = "solid" } } }, pool = { look = { kind = "mesh", shape = "box", w = 20, h = 3, d = 20, r = 60, g = 120, b = 200 }, behaviours = { { kind = "water", density = 1 } } }, ball = { look = { kind = "mesh", shape = "sphere", w = 0.8, r = 220, g = 80, b = 80 }, behaviours = { { kind = "body", shape = "sphere", mass = 2, buoyancy = 2 } } }, @@ -20,13 +20,13 @@ return { rooms = { { name = "pool", entities = { - { kind = "floor", id = "floor", x = 0, y = -3.2, z = 0 }, - { kind = "pool", id = "pool", x = 0, y = -1.5, z = 0 }, - { kind = "ball", id = "ball", x = 3, y = 3, z = 2 }, - { kind = "boat", id = "boat", x = -4, y = 0.4, z = 6 }, - { kind = "balloon", id = "balloon", x = 6, y = 4, z = -6 }, - { kind = "camera", id = "camera", x = 0, y = 10, z = 16 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -3.2, z = 0 }, + { type = "pool", id = "pool", x = 0, y = -1.5, z = 0 }, + { type = "ball", id = "ball", x = 3, y = 3, z = 2 }, + { type = "boat", id = "boat", x = -4, y = 0.4, z = 6 }, + { type = "balloon", id = "balloon", x = 6, y = 4, z = -6 }, + { type = "camera", id = "camera", x = 0, y = 10, z = 16 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, diff --git a/testScripts/author/qte.game b/testScripts/author/qte.game index 38a6a8cc8..fc2028c4f 100644 --- a/testScripts/author/qte.game +++ b/testScripts/author/qte.game @@ -6,7 +6,7 @@ return { layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 180 }, { kind = "overlay" } }, vars = { score = 0 }, - kinds = { + types = { prompt = { look = { kind = "text", text = "", r = 255, g = 220, b = 80 } }, verdict = { look = { kind = "text", text = "", r = 160, g = 255, b = 180 } }, readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } @@ -15,9 +15,9 @@ return { rooms = { { name = "scene", entities = { - { kind = "prompt", id = "prompt", x = 250, y = 60 }, - { kind = "verdict", id = "verdict", x = 250, y = 100 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "prompt", id = "prompt", x = 250, y = 60 }, + { type = "verdict", id = "verdict", x = 250, y = 100 }, + { type = "readout", id = "readout", x = 12, y = 12 } } } }, diff --git a/testScripts/author/racing.game b/testScripts/author/racing.game index 0bf34f7cd..c2cbdddf7 100644 --- a/testScripts/author/racing.game +++ b/testScripts/author/racing.game @@ -7,7 +7,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 90, g = 90, b = 110 }, fov = 60 }, { kind = "overlay" } }, vars = { score = 0, laps = 0, next = 1 }, - kinds = { + types = { ground = { look = { kind = "mesh", shape = "box", w = 120, h = 0.4, d = 200, r = 90, g = 100, b = 90 }, behaviours = { { kind = "solid" } } }, car = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.6, d = 3.2, r = 220, g = 60, b = 60 }, behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN" }, @@ -27,15 +27,15 @@ return { rooms = { { name = "track", entities = { - { kind = "ground", id = "ground", x = 0, y = -0.2, z = -40 }, - { kind = "car", id = "car", x = 0, y = 0.8, z = 10 }, - { kind = "rival", id = "rival", x = 4, y = 0.8, z = 12 }, - { kind = "gate", id = "gate1", x = 0, y = 1.5, z = -10, vars = { index = 1 } }, - { kind = "gate", id = "gate2", x = 0, y = 1.5, z = -35, vars = { index = 2 } }, - { kind = "camera", id = "camera", x = 0, y = 4, z = 19 }, - { kind = "bar", id = "bar", x = -6, y = 2, z = -20 }, - { kind = "hover", id = "hover", x = 8, y = 0.4, z = 10 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "ground", id = "ground", x = 0, y = -0.2, z = -40 }, + { type = "car", id = "car", x = 0, y = 0.8, z = 10 }, + { type = "rival", id = "rival", x = 4, y = 0.8, z = 12 }, + { type = "gate", id = "gate1", x = 0, y = 1.5, z = -10, vars = { index = 1 } }, + { type = "gate", id = "gate2", x = 0, y = 1.5, z = -35, vars = { index = 2 } }, + { type = "camera", id = "camera", x = 0, y = 4, z = 19 }, + { type = "bar", id = "bar", x = -6, y = 2, z = -20 }, + { type = "hover", id = "hover", x = 8, y = 0.4, z = 10 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } }, tracks = { { name = "lap", key = "time", points = { { at = 0, x = 4, y = 0, z = -10 }, { at = 1, x = 4, y = 0, z = -35 }, { at = 2, x = 4, y = 0, z = -70 } } } diff --git a/testScripts/author/railshooter.game b/testScripts/author/railshooter.game index e77b63db7..343320ddb 100644 --- a/testScripts/author/railshooter.game +++ b/testScripts/author/railshooter.game @@ -10,10 +10,11 @@ return { { kind = "overlay" } }, vars = { score = 0, misses = 0, health = 3, bites = 0, stops = 0 }, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.2, d = 100, r = 70, g = 75, b = 90 }, behaviours = { { kind = "solid" } } }, wall = { look = { kind = "mesh", shape = "box", w = 1, h = 3, d = 12, r = 120, g = 60, b = 60 }, behaviours = { { kind = "solid" } } }, camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "rail", track = "rail" } } }, + puff = { look = { kind = "billboard", file = "testScripts/crate.png", height = 0.6, w = 0.6, h = 0.6, d = 0.6 } }, gun = { look = { kind = "none" }, vars = { ammo = 6 }, behaviours = { { kind = "gun", player = 1, ammo = 6, reload = "offscreen" } } }, zombie = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 }, vars = { health = 2 }, @@ -28,19 +29,22 @@ return { rooms = { { name = "street", entities = { - { kind = "floor", id = "floor", x = 0, y = -0.1, z = -30 }, - { kind = "wall", id = "wallL", x = -6, y = 1.5, z = -12 }, - { kind = "wall", id = "wallR", x = 6, y = 1.5, z = -12 }, - { kind = "camera", id = "camera", x = 0, y = 1.6, z = 0 }, - { kind = "gun", id = "gun1", x = 0, y = 0, z = 0 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.1, z = -30 }, + { type = "wall", id = "wallL", x = -6, y = 1.5, z = -12 }, + { type = "wall", id = "wallR", x = 6, y = 1.5, z = -12 }, + { type = "camera", id = "camera", x = 0, y = 1.6, z = 0 }, + { type = "gun", id = "gun1", x = 0, y = 0, z = 0 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } }, tracks = { { name = "rail", key = "time", points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.2, -10 } }, { at = 4, x = 0, y = 1.6, z = -6, look = { 0, 1.2, -16 }, stop = "hall" }, { at = 10, x = 2, y = 1.6, z = -16, look = { 2, 1.2, -26 }, stop = "yard" }, - { at = 14, x = 2, y = 1.6, z = -22, look = { 2, 1.2, -32 } } } } + { at = 14, x = 2, y = 1.6, z = -22, look = { 2, 1.2, -32 } } } }, + { name = "puffs", key = "stop", + spawns = { { at = "hall", type = "puff", count = 2, every = 0.2, x = 0, y = 1, z = -10 }, + { at = "yard", type = "puff", count = 1, from = "wallL, wallR" } } } } } }, @@ -48,40 +52,40 @@ return { { note = "The hall: three come out of the dark", on = "stopped", name = "hall", act = { { "addVar", name = "stops", amount = 1 }, - { "spawn", kind = "zombie", x = -2, y = 0, z = -14 }, - { "spawn", kind = "zombie", x = 2, y = 0, z = -15 }, - { "spawn", kind = "zombie", x = 0, y = 0, z = -17 } } }, + { "spawn", type = "zombie", x = -2, y = 0, z = -14 }, + { "spawn", type = "zombie", x = 2, y = 0, z = -15 }, + { "spawn", type = "zombie", x = 0, y = 0, z = -17 } } }, { note = "The yard: four more", on = "stopped", name = "yard", act = { { "addVar", name = "stops", amount = 1 }, - { "spawn", kind = "zombie", x = -1, y = 0, z = -24 }, - { "spawn", kind = "zombie", x = 4, y = 0, z = -25 }, - { "spawn", kind = "zombie", x = 2, y = 0, z = -27 }, - { "spawn", kind = "zombie", x = 0, y = 0, z = -28 } } }, + { "spawn", type = "zombie", x = -1, y = 0, z = -24 }, + { "spawn", type = "zombie", x = 4, y = 0, z = -25 }, + { "spawn", type = "zombie", x = 2, y = 0, z = -27 }, + { "spawn", type = "zombie", x = 0, y = 0, z = -28 } } }, { note = "A zombie starts walking the moment it is made", - on = "spawn", kind = "zombie", + on = "spawn", type = "zombie", act = { { "setState", entity = "self", state = "walk" } } }, { note = "A shot lands", - on = "hit", kind = "zombie", + on = "hit", type = "zombie", act = { { "damage", entity = "self", amount = 1 }, { "addScore", amount = 100 } } }, { note = "A headshot lands harder", - on = "hit", kind = "zombie", + on = "hit", type = "zombie", when = { { "hitPart", part = "head" } }, act = { { "damage", entity = "self", amount = 2 }, { "addScore", amount = 300 } } }, { note = "It reaches the camera and rears up", - on = "arrived", kind = "zombie", + on = "arrived", type = "zombie", act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, { note = "The bite lands unless it was killed in time", - on = "timer", name = "bite", kind = "zombie", + on = "timer", name = "bite", type = "zombie", when = { { "inState", entity = "self", state = "attack" } }, act = { { "addVar", name = "health", amount = -1 }, { "addVar", name = "bites", amount = 1 }, diff --git a/testScripts/author/rhythm.game b/testScripts/author/rhythm.game index 18126aac1..7f2a53be0 100644 --- a/testScripts/author/rhythm.game +++ b/testScripts/author/rhythm.game @@ -7,7 +7,7 @@ return { layers = { { kind = "overlay" }, { kind = "music", file = "testScripts/tune.mp3", volume = 60 } }, vars = { score = 0, hits = 0, misses = 0, early = 0 }, - kinds = { + types = { lane = { look = { kind = "box", w = 60, h = 400, r = 40, g = 40, b = 60 } }, marker = { look = { kind = "box", w = 80, h = 6, r = 200, g = 200, b = 220 } }, note = { look = { kind = "box", w = 50, h = 16, r = 255, g = 200, b = 60 }, @@ -18,9 +18,9 @@ return { rooms = { { name = "stage", entities = { - { kind = "lane", id = "lane", x = 360, y = 240 }, - { kind = "marker", id = "marker", x = 360, y = 400 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "lane", id = "lane", x = 360, y = 240 }, + { type = "marker", id = "marker", x = 360, y = 400 }, + { type = "readout", id = "readout", x = 12, y = 12 } }, tracks = { { name = "notes", key = "time", points = { { at = 1.0 }, { at = 2.0 }, { at = 2.5 }, { at = 3.5 } } } @@ -31,7 +31,7 @@ return { { note = "A note falls two seconds before its time", on = "frame", each = "lane", when = { { "every", seconds = 0.5, tag = "spawn" }, { "test", expr = "time < 2" } }, - act = { { "spawn", kind = "note", x = 360, y = 0 } } }, + act = { { "spawn", type = "note", x = 360, y = 0 } } }, { note = "A press on a note at the marker", on = "pressed", key = "SPACE", each = "note", diff --git a/testScripts/author/shmup.game b/testScripts/author/shmup.game index 38999c759..57e7f05c7 100644 --- a/testScripts/author/shmup.game +++ b/testScripts/author/shmup.game @@ -6,7 +6,7 @@ return { layers = { { kind = "overlay" }, { kind = "hud", document = "testScripts/author/hud.rml", bind = { score = "score", lives = "lives" } } }, vars = { score = 0, lives = 3 }, - kinds = { + types = { ship = { look = { kind = "box", w = 28, h = 20, r = 120, g = 220, b = 255 }, vars = { health = 1 }, behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN", fire = "SPACE" }, @@ -29,8 +29,8 @@ return { rooms = { { name = "space", entities = { - { kind = "ship", id = "ship", x = 360, y = 420 }, - { kind = "hive", id = "hive", x = 360, y = -20 } + { type = "ship", id = "ship", x = 360, y = 420 }, + { type = "hive", id = "hive", x = 360, y = -20 } } } }, @@ -42,7 +42,7 @@ return { { "addScore", amount = 10 } } }, { note = "A raider dies", - on = "death", kind = "raider", + on = "death", type = "raider", act = { { "emit", entity = "self", count = 24, r = 255, g = 120, b = 60 }, { "addScore", amount = 50 } } }, @@ -52,7 +52,7 @@ return { { "damage", entity = "other", amount = 1 } } }, { note = "The ship is lost", - on = "death", kind = "ship", + on = "death", type = "ship", act = { { "addVar", name = "lives", amount = -1 }, { "flash", r = 255, g = 60, b = 60, seconds = 0.3 }, { "setVar", entity = "self", name = "health", value = 1 }, diff --git a/testScripts/author/space.game b/testScripts/author/space.game index 8c5dde8c1..c87599d8e 100644 --- a/testScripts/author/space.game +++ b/testScripts/author/space.game @@ -6,7 +6,7 @@ return { layers = { { kind = "scene3d", gravity = 0, ambient = { r = 40, g = 40, b = 70 }, fov = 65 }, { kind = "overlay" } }, vars = { score = 0, rocks = 0 }, - kinds = { + types = { ship = { look = { kind = "mesh", shape = "box", w = 1.2, h = 0.4, d = 2.4, r = 200, g = 210, b = 240 }, behaviours = { { kind = "body", mass = 50, friction = 0 }, { kind = "thrust", force = 400, turn = 1.2 }, @@ -25,10 +25,10 @@ return { rooms = { { name = "belt", entities = { - { kind = "ship", id = "ship", x = 0, y = 0, z = 0 }, - { kind = "belt", id = "belt", x = 0, y = 0, z = -60 }, - { kind = "camera", id = "camera", x = 0, y = 3, z = 8 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "ship", id = "ship", x = 0, y = 0, z = 0 }, + { type = "belt", id = "belt", x = 0, y = 0, z = -60 }, + { type = "camera", id = "camera", x = 0, y = 3, z = 8 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, @@ -38,7 +38,7 @@ return { act = { { "destroy", entity = "self" }, { "damage", entity = "other", amount = 1 } } }, { note = "A rock is broken", - on = "death", kind = "rock", + on = "death", type = "rock", act = { { "addScore", amount = 25 }, { "addVar", name = "rocks", amount = 1 } } }, { note = "The readout, every frame", diff --git a/testScripts/author/tail.game b/testScripts/author/tail.game index 829c99eb6..2b68a03a8 100644 --- a/testScripts/author/tail.game +++ b/testScripts/author/tail.game @@ -6,7 +6,7 @@ return { layers = { { kind = "overlay" } }, vars = { score = 0, notes = 0 }, - kinds = { + types = { runner = { look = { kind = "sprite", file = "testScripts/strip.png", frames = 5, anchor = "feet" }, behaviours = { { kind = "frames", fps = 10, states = "idle=1-1, walk=3-5, walkLeft=1-2" }, { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, @@ -26,13 +26,13 @@ return { rooms = { { name = "stage", entities = { - { kind = "floor", id = "floor", x = 200, y = 300 }, - { kind = "runner", id = "runner", x = 360, y = 300 }, - { kind = "mirrored", id = "mirror", x = 360, y = 420 }, - { kind = "mirrored", id = "tilted", x = 120, y = 200, rz = 30 }, - { kind = "spinner", id = "spinner", x = 560, y = 200, rz = 45 }, - { kind = "fire", id = "fire", x = 640, y = 400 }, - { kind = "readout", id = "readout", x = 12, y = 12 } + { type = "floor", id = "floor", x = 200, y = 300 }, + { type = "runner", id = "runner", x = 360, y = 300 }, + { type = "mirrored", id = "mirror", x = 360, y = 420 }, + { type = "mirrored", id = "tilted", x = 120, y = 200, rz = 30 }, + { type = "spinner", id = "spinner", x = 560, y = 200, rz = 45 }, + { type = "fire", id = "fire", x = 640, y = 400 }, + { type = "readout", id = "readout", x = 12, y = 12 } } } }, diff --git a/testScripts/author/thirdperson.game b/testScripts/author/thirdperson.game index d31b9c0c9..b296aba44 100644 --- a/testScripts/author/thirdperson.game +++ b/testScripts/author/thirdperson.game @@ -7,7 +7,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 60 }, { kind = "overlay" } }, vars = { score = 0, health = 5, swings = 0 }, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 80, g = 100, b = 80 }, behaviours = { { kind = "solid" } } }, hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.9, d = 0.6 }, behaviours = { { kind = "keys", player = 1, left = "A", right = "D", up = "W", down = "S" }, @@ -21,24 +21,26 @@ return { { kind = "animator", idle = "Survey", walk = "Walk", attack = "Run" }, { kind = "timer", name = "bite", after = 1.5, start = false } } }, hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "enemy", every = 1.5, max = 3, total = 4, points = { { x = -8, y = 0, z = -8 }, { x = 8, y = 0, z = -8 } } } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }, + sign = { look = { kind = "text3d", text = "ARENA", height = 0.6 } } }, rooms = { { name = "field", navFrom = { "floor" }, entities = { - { kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { kind = "hero", id = "hero", x = 0, y = 0.2, z = 4 }, - { kind = "camera", id = "camera", x = 0, y = 2, z = 10 }, - { kind = "hive", id = "hive", x = 0, y = 0, z = -8 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, + { type = "hero", id = "hero", x = 0, y = 0.2, z = 4 }, + { type = "camera", id = "camera", x = 0, y = 2, z = 10 }, + { type = "hive", id = "hive", x = 0, y = 0, z = -8 }, + { type = "sign", id = "sign", x = 0, y = 2, z = -8 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, rules = { { note = "An enemy walks from the moment it is made", - on = "spawn", kind = "enemy", + on = "spawn", type = "enemy", act = { { "setState", entity = "self", state = "walk" } } }, { note = "Moving means walking", @@ -57,15 +59,15 @@ return { act = { { "damage", entity = "self", amount = 1 }, { "addVar", name = "swings", amount = 1 } } }, { note = "A kill", - on = "death", kind = "enemy", + on = "death", type = "enemy", act = { { "addScore", amount = 100 } } }, { note = "It reaches the hero and rears up", - on = "arrived", kind = "enemy", + on = "arrived", type = "enemy", act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, { note = "The bite lands unless it was killed", - on = "timer", name = "bite", kind = "enemy", + on = "timer", name = "bite", type = "enemy", when = { { "inState", entity = "self", state = "attack" } }, act = { { "addVar", name = "health", amount = -1 }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, diff --git a/testScripts/author/towerdefence.game b/testScripts/author/towerdefence.game index 09199b633..5ac2a7ef1 100644 --- a/testScripts/author/towerdefence.game +++ b/testScripts/author/towerdefence.game @@ -8,7 +8,7 @@ return { layers = { { kind = "scene3d", ambient = { r = 80, g = 90, b = 80 }, fov = 55 }, { kind = "overlay" } }, vars = { score = 0, gold = 120, lives = 5, killed = 0 }, - kinds = { + types = { floor = { look = { kind = "mesh", shape = "box", w = 30, h = 0.4, d = 24, r = 80, g = 110, b = 80 }, behaviours = { { kind = "solid" } } }, creep = { look = { kind = "mesh", shape = "box", w = 0.6, h = 0.8, d = 0.6, r = 120, g = 200, b = 90 }, vars = { health = 3 }, @@ -28,10 +28,10 @@ return { { name = "field", navFrom = { "floor" }, entities = { - { kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { kind = "hive", id = "hive", x = -12, y = 0, z = -6 }, - { kind = "camera", id = "camera", x = 0, y = 20, z = 14 }, - { kind = "readout", id = "readout", x = 12, y = 12, z = 0 } + { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, + { type = "hive", id = "hive", x = -12, y = 0, z = -6 }, + { type = "camera", id = "camera", x = 0, y = 20, z = 14 }, + { type = "readout", id = "readout", x = 12, y = 12, z = 0 } } } }, @@ -39,7 +39,7 @@ return { { note = "A click on the floor builds a turret, for fifty gold", on = "pressed", switch = "SWITCH_BUTTON3", when = { { "test", expr = "gold >= 50" } }, - act = { { "spawnAtPointer", kind = "turret", player = 1 }, + act = { { "spawnAtPointer", type = "turret", player = 1 }, { "addVar", name = "gold", amount = -50 } } }, { note = "A bolt lands", @@ -48,13 +48,13 @@ return { { "damage", entity = "other", amount = 1 } } }, { note = "A creep dies", - on = "death", kind = "creep", + on = "death", type = "creep", act = { { "addVar", name = "gold", amount = 20 }, { "addScore", amount = 10 }, { "addVar", name = "killed", amount = 1 } } }, { note = "A creep reaches the end of the lane", - on = "patrolEnd", kind = "creep", + on = "patrolEnd", type = "creep", act = { { "destroy", entity = "self" }, { "addVar", name = "lives", amount = -1 } } }, diff --git a/testScripts/author/waves.game b/testScripts/author/waves.game new file mode 100644 index 000000000..d125a723e --- /dev/null +++ b/testScripts/author/waves.game @@ -0,0 +1,37 @@ +-- Waves and soundDone: a track of spawns by time -- three rocks from two spots in turn, then +-- two at a point -- and a bell that says when its clip has ended. +return { + title = "Author test: waves", + players = 1, + layers = { { kind = "world2d" }, { kind = "overlay" } }, + vars = { made = 0, rung = 0 }, + + types = { + rock = { look = { kind = "box", w = 20, h = 20, r = 200, g = 120, b = 60 } }, + spot = { look = { kind = "none" } }, + bell = { look = { kind = "none" }, behaviours = { { kind = "sound", spawn = "testScripts/crackle.wav", volume = 40 } } } + }, + + rooms = { + { name = "field", + entities = { + { type = "spot", id = "left", x = 60, y = 240 }, + { type = "spot", id = "right", x = 660, y = 240 }, + { type = "bell", id = "bell", x = 0, y = 0 } + }, + tracks = { + { name = "waves", key = "time", + spawns = { { at = 0.5, type = "rock", count = 3, every = 0.2, from = "left, right" }, + { at = 1.5, type = "rock", count = 2, x = 360, y = 100 } } } + } } + }, + + rules = { + { note = "Count the rocks as they come", + on = "spawn", type = "rock", + act = { { "addVar", name = "made", amount = 1 } } }, + { note = "The bell's clip ended", + on = "soundDone", type = "bell", + act = { { "addVar", name = "rung", amount = 1 } } } + } +} diff --git a/testScripts/scene52.singe b/testScripts/scene52.singe index fb91778f2..6469b7d1f 100644 --- a/testScripts/scene52.singe +++ b/testScripts/scene52.singe @@ -1,7 +1,7 @@ -- Forge (FORGE.md): compiles all four sample descriptions and plays the platformer. -- -- The descriptions share nothing -- physics with a character, disc frames and time windows, a --- shoot-em-up of spawned kinds, a gun over video -- so compiling all of them here is the test +-- shoot-em-up of spawned types, a gun over video -- so compiling all of them here is the test -- that the core has no genre baked into it. -- Nothing in this scene knows what a platformer is; it drives keys and looks at the result. dofile("Forge/AuthorCompile.singe") diff --git a/testScripts/scene54.singe b/testScripts/scene54.singe index 96605500a..ff4b7db44 100644 --- a/testScripts/scene54.singe +++ b/testScripts/scene54.singe @@ -84,7 +84,7 @@ local function step() local built = forgeBuild(out .. "edited.singe") local text = assert(io.open(built, "r")):read("a") - if text:find('id = "prize", kind = "prize", x = 300') then + if text:find('id = "prize", type = "prize", x = 300') then debugPrint("FORGE the compiled game carries the new position") else debugPrint("FORGE FAIL the compiled game does not carry the new position") diff --git a/testScripts/scene55.singe b/testScripts/scene55.singe index 444424282..24e2aaa5e 100644 --- a/testScripts/scene55.singe +++ b/testScripts/scene55.singe @@ -50,8 +50,8 @@ end local function step() if stage == 1 then - key(SCANCODE.TAB) -- entities to kinds, - key(SCANCODE.TAB) -- kinds to rules. + key(SCANCODE.TAB) -- entities to types, + key(SCANCODE.TAB) -- types to rules. debugPrint("RULES mode is now " .. FORGE.mode .. ", rules " .. #FORGE.game.rules) elseif stage == 2 then diff --git a/testScripts/scene57.singe b/testScripts/scene57.singe index beefb5f82..a823ee891 100644 --- a/testScripts/scene57.singe +++ b/testScripts/scene57.singe @@ -53,7 +53,7 @@ local function step() end key(SCANCODE.RETURN) -- id typeIn("coin") - key(SCANCODE.RETURN) -- kind + key(SCANCODE.RETURN) -- type typeIn("hero") key(SCANCODE.RETURN) -- x typeIn("300") @@ -63,10 +63,10 @@ local function step() key(SCANCODE.ESCAPE) -- untouched local coin = forgeRoom().entities[#forgeRoom().entities] - if coin.id == "coin" and coin.kind == "hero" and coin.x == 300 and coin.y == 200 then - debugPrint("GAME typed an entity in: " .. coin.id .. " the " .. coin.kind .. " at " .. coin.x .. "," .. coin.y) + if coin.id == "coin" and coin.type == "hero" and coin.x == 300 and coin.y == 200 then + debugPrint("GAME typed an entity in: " .. coin.id .. " the " .. coin.type .. " at " .. coin.x .. "," .. coin.y) else - debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " the " .. tostring(coin.kind) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y)) + debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " the " .. tostring(coin.type) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y)) end elseif stage == 3 then @@ -105,7 +105,7 @@ local function step() -- A rule from the pickers: N, E picks an event, C a condition, T an action. local rules = #FORGE.game.rules - key(SCANCODE.TAB) -- kinds, + key(SCANCODE.TAB) -- types, key(SCANCODE.TAB) -- rules. key(SCANCODE.N) key(SCANCODE.E) diff --git a/testScripts/scene58.singe b/testScripts/scene58.singe index c615403cd..14e85d514 100644 --- a/testScripts/scene58.singe +++ b/testScripts/scene58.singe @@ -1,5 +1,5 @@ -- Forge milestone 1, genre one: the shoot-em-up. Kinds spawned and destroyed by the dozen, --- collision events between kinds, health and death, a spawner, a HUD document bound to vars, a +-- collision events between types, health and death, a spawner, a HUD document bound to vars, a -- sequence (say waits), a flash, and a game over -- driven by what the game is doing. dofile("Forge/AuthorCompile.singe") diff --git a/testScripts/scene59.singe b/testScripts/scene59.singe index 2f1b28b33..c4d7bf6b8 100644 --- a/testScripts/scene59.singe +++ b/testScripts/scene59.singe @@ -22,7 +22,7 @@ local hostage = 0 local realEvent = authorEvent function authorEvent(name, event) if name == "hit" then - if event.self.kind == "bandit" then hits = hits + 1 elseif event.self.kind == "hostage" then hostage = hostage + 1 end + if event.self.type == "bandit" then hits = hits + 1 elseif event.self.type == "hostage" then hostage = hostage + 1 end elseif name == "miss" then misses = misses + 1 end diff --git a/testScripts/scene61.singe b/testScripts/scene61.singe index e790f55fb..c0747cabf 100644 --- a/testScripts/scene61.singe +++ b/testScripts/scene61.singe @@ -96,6 +96,8 @@ function onOverlayUpdate() if kills < 7 then debugPrint("RAIL FAIL not every zombie was killed") end if heads == 0 then debugPrint("RAIL FAIL no shot was reported on the head") end if not ended then debugPrint("RAIL FAIL the rail never ended") end + -- The waves track keyed by the stops: two puffs at the hall, one at the yard. + if authorCount("puff") ~= 3 then debugPrint("RAIL FAIL the stops' waves made " .. authorCount("puff") .. " puffs, not 3") end singeQuit() end diff --git a/testScripts/scene63.singe b/testScripts/scene63.singe index 0e1c0d9c2..6dc5dfdd4 100644 --- a/testScripts/scene63.singe +++ b/testScripts/scene63.singe @@ -1,5 +1,5 @@ -- The 3D viewport (FORGE.md milestone 2): a room with a scene3d layer is shown as the scene, --- built from the kinds' looks under an orbiting editor camera. Entities are picked and dragged +-- built from the types' looks under an orbiting editor camera. Entities are picked and dragged -- by where they project, the camera orbits by key, and a rail point is put where the camera -- stands. FORGE_LIBRARY = true diff --git a/testScripts/scene69.singe b/testScripts/scene69.singe index 7cb3f94d3..e0e29c1e5 100644 --- a/testScripts/scene69.singe +++ b/testScripts/scene69.singe @@ -64,21 +64,21 @@ local function step() key(SCANCODE.TAB) -- tracks key(SCANCODE.TAB) -- dialogues key(SCANCODE.TAB) -- entities - key(SCANCODE.TAB) -- kinds - FORGE.kindName = "hero" - forgeKindSet(FORGE.game.kinds.hero, "look", "sprite") - FORGE.lastField = "look.anchor" + key(SCANCODE.TAB) -- types + FORGE.typeName = "hero" + forgeTypeSet(FORGE.game.types.hero, "look", "sprite") + FORGE.lastField = "look.faces" -- the field before look.file key(SCANCODE.RETURN) -- look.file: the picker if FORGE.picker == nil then debugPrint("EDIT FAIL a file field did not open the picker") end typeIn("testScripts/strip") key(SCANCODE.RETURN) - debugPrint("EDIT the hero's sprite file is now " .. tostring(FORGE.game.kinds.hero.look.file)) - if FORGE.game.kinds.hero.look.file ~= "testScripts/strip.png" then + debugPrint("EDIT the hero's sprite file is now " .. tostring(FORGE.game.types.hero.look.file)) + if FORGE.game.types.hero.look.file ~= "testScripts/strip.png" then debugPrint("EDIT FAIL the file picker did not set the file") end - forgeKindSet(FORGE.game.kinds.hero, "look", "box") + forgeTypeSet(FORGE.game.types.hero, "look", "box") elseif stage == 3 then -- A walk area drawn corner by corner, then a hotspot's outline. @@ -110,7 +110,7 @@ local function step() forgePress(630, 300) forgePress(570, 300) key(SCANCODE.RETURN) - local spot = FORGE.game.kinds.door.behaviours[1] + local spot = FORGE.game.types.door.behaviours[1] if spot.polygon and #spot.polygon == 8 then debugPrint("EDIT the door's outline has four corners") @@ -120,7 +120,7 @@ local function step() elseif stage == 4 then -- The dialogue outline: a node added and named, a choice added, an action picked for it. - key(SCANCODE.TAB) -- kinds + key(SCANCODE.TAB) -- types key(SCANCODE.TAB) -- rules key(SCANCODE.TAB) -- tracks key(SCANCODE.TAB) -- dialogues diff --git a/testScripts/scene70.singe b/testScripts/scene70.singe index 60bc60082..12f22b6b7 100644 --- a/testScripts/scene70.singe +++ b/testScripts/scene70.singe @@ -68,7 +68,7 @@ function onOverlayUpdate() debugPrint(string.format("TAIL the spinner began at 45 and is at %.0f degrees; the fire has an emitter: %s", spinner.vars.angle, tostring(authorEntity("fire").emitter ~= nil))) if spinner.vars.angle < 100 then debugPrint("TAIL FAIL the spinner did not turn") end - local problems = authorCheck({ kinds = { thing = { look = { kind = "box", widthh = 3 }, behaviours = { { kind = "drift", vxx = 1 } } } }, rooms = { { name = "r", entities = {} } }, rules = { { on = "pressed", keyy = "A" } } }) + local problems = authorCheck({ types = { thing = { look = { kind = "box", widthh = 3 }, behaviours = { { kind = "drift", vxx = 1 } } } }, rooms = { { name = "r", entities = {} } }, rules = { { on = "pressed", keyy = "A" } } }) debugPrint("TAIL the checker names " .. #problems .. " misspelt fields: " .. table.concat(problems, " | ")) if #problems ~= 3 then debugPrint("TAIL FAIL expected three misspellings to be named") end diff --git a/testScripts/scene75.singe b/testScripts/scene75.singe index 37d1a7fdc..0ba9e6049 100644 --- a/testScripts/scene75.singe +++ b/testScripts/scene75.singe @@ -70,6 +70,8 @@ function onOverlayUpdate() if kills < 3 then debugPrint("TPS FAIL fewer than three enemies were cut down") end if math.abs(camera.yaw - yawStart) < 60 then debugPrint("TPS FAIL the camera did not orbit") end if math.abs(hx - startX) < 1 then debugPrint("TPS FAIL walking forward after the orbit did not move the hero sideways in the world") end + local sign = authorEntity("sign") + if (sign == nil) or (sign.vars.text ~= "ARENA") or (sign.node == nil) then debugPrint("TPS FAIL the text3d sign is not standing in the field") end singeQuit() end diff --git a/testScripts/scene82.singe b/testScripts/scene82.singe index 7f708a754..b4b66fbca 100644 --- a/testScripts/scene82.singe +++ b/testScripts/scene82.singe @@ -1,6 +1,6 @@ --- The 2D canvas's turn handle and the kinds panel's thumbnails (FORGE.md): the selected entity +-- The 2D canvas's turn handle and the types panel's thumbnails (FORGE.md): the selected entity -- carries a yellow arm the way its rz points, dragged round it to turn it, as the scene's ry arm --- does; and each kind in the panel shows what it looks like beside its name. +-- does; and each type in the panel shows what it looks like beside its name. FORGE_LIBRARY = true dofile("Forge/Forge.singe") @@ -73,8 +73,31 @@ local function step() selectEntity("tilted") elseif stage == 4 then - -- The kinds panel: a thumbnail in front of every name. - while FORGE.mode ~= "kinds" do + -- The types panel: a thumbnail in front of every name. + -- A click on a row of the panel does what the arrows do, and a click on a field starts + -- typing it, through the dispatcher the rows are wired to. + -- Real clicks, dispatched on the rows as RmlUi would, so the engine's handler path is + -- what is tested and not only the dispatcher behind it. + local function click(id) + rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id):DispatchEvent("click", {}) + end + + forgeKey(0, SCANCODE.MAIN_2.value) + click("t2") + local lit = FORGE.typeName + + forgeKey(0, SCANCODE.MAIN_1.value) + click("e2") + local picked = FORGE.selected + click("f3") + local editing = FORGE.editing and FORGE.editing.key + forgeKey(0, SCANCODE.ESCAPE.value) + debugPrint(string.format("TURN clicks: type row 2 lit %s, entity row 2 selected %s, field row 3 typing %s", tostring(lit), tostring(picked), tostring(editing))) + if (picked ~= 2) or (editing ~= "x") then + debugPrint("TURN FAIL a click on a row should select it and a click on a field should start typing it") + end + forgeKey(0, SCANCODE.MAIN_2.value) + while FORGE.mode ~= "types" do forgeKey(0, SCANCODE.TAB.value) end forgeRefresh() @@ -82,9 +105,9 @@ local function step() local images = select(2, rml:gsub(" from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +-- A type from the kit: A adds one, and the form names it, gives it a sprite from the kit, and +-- makes it a trigger. +local function kitType(name, file, anchor) + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", name) + form("look", "sprite") + form("look.file", file) + if anchor then + form("look.anchor", anchor) + end + form("behaviours", "trigger") +end + + +-- An entity of the lit type, added and dragged to a spot. +local function place(x, y) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + local entry = forgeRoom().entities[FORGE.selected] + + forgePress(entry.x, entry.y) + forgeDrag(x, y) + forgeRelease() + debugPrint(string.format("%s placed %s at %d, %d (held was %s)", TUTORIAL.tag, entry.id, entry.x, entry.y, tostring(FORGE.selected))) +end + + +local function step() + if stage == 1 then + kitType("coin", "coin.png") + tutorialShot("coinType") + + elseif stage == 2 then + place(300, 392) + key(SCANCODE.D) + forgePress(320, 412) + forgeDrag(420, 392) + forgeRelease() + key(SCANCODE.D) + forgePress(440, 412) + forgeDrag(560, 300) + forgeRelease() + tutorialShot("coins") + + elseif stage == 3 then + kitType("spike", "spike.png", "feet") + place(640, 420) + kitType("door", "door.png", "feet") + place(700, 420) + tutorialShot("placed") + + elseif stage == 4 then + -- The first rule: a coin is taken. + key(SCANCODE.MAIN_3) + key(SCANCODE.N) + form("note", "Take a coin") + key(SCANCODE.E) + pick("enter") + form("a", "coin") + form("b", "hero") + key(SCANCODE.T) + pick("addScore") + form("amount", 10) + key(SCANCODE.T) + pick("destroy") + key(SCANCODE.T) + pick("playSound") + form("file", "coin.wav") + tutorialShot("rule") + + elseif stage == 5 then + key(SCANCODE.N) + form("note", "Spikes hurt") + key(SCANCODE.E) + pick("enter") + form("a", "spike") + form("b", "hero") + key(SCANCODE.T) + pick("moveTo") + form("entity", "hero") + form("x", 200) + form("y", 380) + key(SCANCODE.T) + pick("playSound") + form("file", "hit.wav") + key(SCANCODE.N) + form("note", "Through the door") + key(SCANCODE.E) + pick("enter") + form("a", "door") + form("b", "hero") + key(SCANCODE.T) + pick("goTo") + form("room", "cave") + form("entity", "hero") + form("x", 60) + form("y", 380) + tutorialShot("rules") + + elseif stage == 6 then + -- The second room: ground, a coin, and the readout. + key(SCANCODE.MAIN_1) + key(SCANCODE.N) + form("name", "cave") + key(SCANCODE.MAIN_2) + lightType("ground") + place(360, 440) + key(SCANCODE.MAIN_2) + lightType("readout") + place(12, 12) + key(SCANCODE.MAIN_2) + lightType("coin") + place(400, 392) + tutorialShot("cave") + + elseif stage == 7 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + authorKeyDown(0, SCANCODE.RIGHT.value) + + elseif stage == 10 then + tutorialShot("playing") + elseif stage == 12 then + local coins = authorCount and authorCount("coin") or -1 + + debugPrint(TUTORIAL.tag .. " after running right the score is " .. tostring(AUTHOR_VARS and AUTHOR_VARS.score) .. " and " .. tostring(coins) .. " coins remain") + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 12 then + stage = stage + 1 + step() + end + if game ~= nil then + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene85.singe b/testScripts/scene85.singe new file mode 100644 index 000000000..f47825133 --- /dev/null +++ b/testScripts/scene85.singe @@ -0,0 +1,130 @@ +-- Forge tutorial 3, "Sprites and sound": the hero from the kit's sheet, mirrored as it turns, +-- coins that spin, a jump with a sound on the press, and music. Continues from tutorial 2. +TUTORIAL = { number = 3, name = "03-sprites", tag = "TUTORIAL3" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil + +tutorialBegin("02-platformer") + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +-- Walks the rules with the arrows to a rule, and to one of its parts. +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function step() + if stage == 1 then + -- The hero from the sheet, with frames stepped by state. + key(SCANCODE.MAIN_2) + lightType("hero") + form("look", "sprite") + form("look.file", "hero.png") + form("look.frames", 8) + form("behaviours", "platformer, frames") + form("frames.fps", 10) + form("frames.states", "idle=1-1, walk=2-5, jump=6-6, fall=7-7") + tutorialShot("heroType") + + elseif stage == 2 then + lightType("coin") + form("behaviours", "trigger, spin") + form("spin.rate", 180) + tutorialShot("coinSpin") + + elseif stage == 3 then + -- The jump rule: a press rather than a hold, and a sound. + key(SCANCODE.MAIN_3) + ruleGo(3, 1) + key(SCANCODE.DELETE) + key(SCANCODE.C) + pick("keyPressed") + form("key", "SPACE") + key(SCANCODE.T) + pick("playSound") + form("file", "jump.wav") + tutorialShot("jumpRule") + + elseif stage == 4 then + key(SCANCODE.N) + form("note", "Music") + key(SCANCODE.E) + pick("roomStart") + key(SCANCODE.T) + pick("playMusic") + form("file", "loop.ogg") + tutorialShot("music") + + elseif stage == 5 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + authorKeyDown(0, SCANCODE.RIGHT.value) + + elseif stage == 7 then + authorKeyUp(0, SCANCODE.RIGHT.value) + authorKeyDown(0, SCANCODE.LEFT.value) + elseif stage == 8 then + tutorialShot("playing") + local hero = authorEntity("hero") + + debugPrint(string.format("%s the hero faces %s in state %s on frame %s, mirrored %s", TUTORIAL.tag, tostring(hero.vars.facing), tostring(hero.vars.state), tostring(hero.vars.frame), tostring(authorMirrored(hero)))) + if (hero.vars.facing ~= "left") or not authorMirrored(hero) then + debugPrint(TUTORIAL.tag .. " FAIL the hero should face left, mirrored") + end + elseif stage == 9 then + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 9 then + stage = stage + 1 + step() + end + if game ~= nil then + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene86.singe b/testScripts/scene86.singe new file mode 100644 index 000000000..dcc7261f6 --- /dev/null +++ b/testScripts/scene86.singe @@ -0,0 +1,232 @@ +-- Forge tutorial 4, "A shoot-em-up": a new game with no gravity, a ship on the keys, shots, +-- rocks from spawners, health and death, lives, and game over. Starts from New game. +TUTORIAL = { number = 4, name = "04-shmup", tag = "TUTORIAL4" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil + +tutorialBegin(nil) + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function kitType(name, file) + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", name) + form("look", "sprite") + form("look.file", file) +end + + +local function place(x, y) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + local entry = forgeRoom().entities[FORGE.selected] + + forgePress(entry.x, entry.y) + forgeDrag(x, y) + forgeRelease() +end + + +-- A rule from scratch: N, its note, E and its event, then the filter fields. +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +-- A part: C or T, its name, and its fields. +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +local function step() + if stage == 1 then + -- Clear the starter: the hero and the ground go, types and all, and so do their rules. + key(SCANCODE.MAIN_1) + selectEntity("hero") + key(SCANCODE.DELETE) + selectEntity("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_2) + lightType("hero") + key(SCANCODE.DELETE) + lightType("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_3) + ruleGo(1, 0) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + -- The game itself: no world, so no gravity; lives to lose. + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "Rocks") + form("vars", "score=0, lives=3") + form("layers", "overlay") + tutorialShot("cleared") + + elseif stage == 2 then + kitType("ship", "ship.png") + form("vars", "health=1") + form("behaviours", "keys, mover, shooter, health, sound") + form("keys.left", "LEFT") + form("keys.right", "RIGHT") + form("keys.up", "UP") + form("keys.down", "DOWN") + form("keys.fire", "SPACE") + form("mover.speed", 260) + form("mover.clamp", "true") + form("shooter.spawns", "shot") + form("shooter.rate", 0.15) + form("shooter.offsetY", -14) + form("health.max", 1) + form("health.keep", "true") + form("sound.hit", "hit.wav") + tutorialShot("ship") + + elseif stage == 3 then + kitType("shot", "shot.png") + form("behaviours", "projectile, sound") + form("projectile.vy", -520) + form("projectile.life", 3) + form("sound.spawn", "shot.wav") + kitType("rock", "rock.png") + form("vars", "health=2") + form("behaviours", "drift, health, sound") + form("drift.vy", 90) + form("health.max", 2) + form("sound.death", "hit.wav") + key(SCANCODE.A) + form("name", "hive") + form("look", "none") + form("behaviours", "spawner") + form("spawner.spawns", "rock") + form("spawner.every", 0.6) + form("spawner.max", 6) + form("spawner.total", 12) + tutorialShot("types") + + elseif stage == 4 then + lightType("ship") + place(360, 420) + key(SCANCODE.MAIN_2) + lightType("hive") + place(360, 20) + key(SCANCODE.D) + forgePress(380, 40) + forgeDrag(560, 20) + forgeRelease() + tutorialShot("placed") + + elseif stage == 5 then + key(SCANCODE.MAIN_3) + rule("A shot lands on a rock", "collision", { { "a", "shot" }, { "b", "rock" } }) + part(SCANCODE.T, "destroy", {}) + part(SCANCODE.T, "damage", { { "entity", "other" }, { "amount", 1 } }) + part(SCANCODE.T, "addScore", { { "amount", 10 } }) + rule("A rock dies", "death", { { "type", "rock" } }) + part(SCANCODE.T, "emit", { { "count", 24 }, { "r", 255 }, { "g", 120 }, { "b", 60 } }) + part(SCANCODE.T, "addScore", { { "amount", 50 } }) + tutorialShot("hitRule") + + elseif stage == 6 then + rule("A rock rams the ship", "collision", { { "a", "rock" }, { "b", "ship" } }) + part(SCANCODE.T, "destroy", {}) + part(SCANCODE.T, "damage", { { "entity", "other" }, { "amount", 1 } }) + rule("The ship is lost", "death", { { "type", "ship" } }) + part(SCANCODE.T, "addVar", { { "name", "lives" }, { "amount", -1 } }) + clear("entity") + part(SCANCODE.T, "flash", { { "r", 255 }, { "g", 60 }, { "b", 60 }, { "seconds", 0.3 } }) + part(SCANCODE.T, "setVar", { { "name", "health" }, { "value", "1" } }) + part(SCANCODE.T, "moveTo", { { "x", 360 }, { "y", 420 } }) + rule("Out of lives", "frame", {}) + part(SCANCODE.C, "test", { { "expr", "lives <= 0" } }) + part(SCANCODE.T, "gameOver", {}) + rule("A rock slips past the bottom", "frame", { { "each", "rock" } }) + part(SCANCODE.C, "test", { { "expr", "self.y > 500" } }) + part(SCANCODE.T, "destroy", {}) + -- The readout, kept from the starter, says the lives too. + ruleGo(1, 1) + form("text", '"score " .. score .. " lives " .. lives') + tutorialShot("rules") + + elseif stage == 7 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + authorKeyDown(0, SCANCODE.SPACE.value) + + elseif stage == 19 then + tutorialShot("playing") + debugPrint(string.format("%s after firing a while: score %s, lives %s, %d rocks, %d shots", TUTORIAL.tag, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.lives), authorCount("rock"), authorCount("shot"))) + elseif stage == 20 then + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 20 then + stage = stage + 1 + step() + end + if game ~= nil then + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene87.singe b/testScripts/scene87.singe new file mode 100644 index 000000000..29ef1d274 --- /dev/null +++ b/testScripts/scene87.singe @@ -0,0 +1,208 @@ +-- Forge tutorial 5, "A quick-time event": the kit's clip as the disc, two windows that want a +-- key, a hit and a miss for each, lives, and the clip looping. Starts from New game. +TUTORIAL = { number = 5, name = "05-qte", tag = "TUTORIAL5" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil +local pressed = false +local shot = false + +tutorialBegin(nil) + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function place(x, y) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + local entry = forgeRoom().entities[FORGE.selected] + + forgePress(entry.x, entry.y) + forgeDrag(x, y) + forgeRelease() +end + + +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +-- One window: the prompt while it is open, the hit once, and the miss when it closes unanswered. +local function window(name, from, to, keyName, prompt) + rule("Window " .. name .. " is open", "frame", {}) + part(SCANCODE.C, "discBetween", { { "from", from }, { "to", to } }) + part(SCANCODE.T, "setText", { { "entity", "prompt" }, { "text", '"' .. prompt .. '"' } }) + rule("Window " .. name .. " answered in time", "frame", {}) + part(SCANCODE.C, "discBetween", { { "from", from }, { "to", to } }) + part(SCANCODE.C, "keyPressed", { { "key", keyName } }) + part(SCANCODE.C, "once", { { "tag", name } }) + part(SCANCODE.T, "setVar", { { "name", "hit" .. name }, { "value", "true" } }) + clear("entity") + part(SCANCODE.T, "addScore", { { "amount", 250 } }) + part(SCANCODE.T, "setText", { { "entity", "verdict" }, { "text", '"HIT"' } }) + rule("Window " .. name .. " closed unanswered", "frameReached", { { "frame", to } }) + part(SCANCODE.C, "test", { { "expr", "not hit" .. name } }) + part(SCANCODE.T, "addVar", { { "name", "lives" }, { "amount", -1 } }) + clear("entity") + part(SCANCODE.T, "setText", { { "entity", "verdict" }, { "text", '"MISS"' } }) + part(SCANCODE.T, "setText", { { "entity", "prompt" }, { "text", '""' } }) +end + + +local function step() + if stage == 1 then + key(SCANCODE.MAIN_1) + selectEntity("hero") + key(SCANCODE.DELETE) + selectEntity("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_2) + lightType("hero") + key(SCANCODE.DELETE) + lightType("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_3) + ruleGo(1, 0) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "Moments") + form("vars", "score=0, lives=3") + form("layers", "disc, overlay") + form("disc.file", "clip.mkv") + tutorialShot("disc") + + elseif stage == 2 then + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", "prompt") + form("look", "text") + form("look.r", 255) + form("look.g", 220) + form("look.b", 80) + place(250, 60) + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", "verdict") + form("look", "text") + form("look.r", 160) + form("look.g", 255) + form("look.b", 180) + place(250, 100) + tutorialShot("prompts") + + elseif stage == 3 then + key(SCANCODE.MAIN_3) + window("one", 72, 120, "SPACE", "PRESS!") + tutorialShot("window") + elseif stage == 4 then + window("two", 150, 200, "UP", "UP!") + rule("The clip runs out: round again", "frameReached", { { "frame", 280 } }) + part(SCANCODE.T, "setVar", { { "name", "hitone" }, { "value", "false" } }) + clear("entity") + part(SCANCODE.T, "setVar", { { "name", "hittwo" }, { "value", "false" } }) + clear("entity") + part(SCANCODE.T, "setText", { { "entity", "verdict" }, { "text", '""' } }) + part(SCANCODE.T, "discTo", { { "frame", 1 } }) + rule("Out of lives", "frame", {}) + part(SCANCODE.C, "test", { { "expr", "lives <= 0" } }) + part(SCANCODE.T, "gameOver", {}) + ruleGo(1, 1) + form("text", '"score " .. score .. " lives " .. lives') + tutorialShot("rules") + + elseif stage == 5 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 5 then + stage = stage + 1 + step() + end + if game ~= nil then + local frame = discGetFrame() + + if (frame >= 84) and not pressed then + pressed = true + authorKeyDown(0, SCANCODE.SPACE.value) + end + if (frame >= 100) and not shot then + shot = true + tutorialShot("playing") + end + if frame >= 210 then + debugPrint(string.format("%s at frame %d the score is %s with %s lives", TUTORIAL.tag, frame, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.lives))) + if (AUTHOR_VARS.score ~= 250) or (AUTHOR_VARS.lives ~= 2) then + debugPrint(TUTORIAL.tag .. " FAIL window one should be hit and window two missed") + end + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene88.singe b/testScripts/scene88.singe new file mode 100644 index 000000000..4dcdb6d28 --- /dev/null +++ b/testScripts/scene88.singe @@ -0,0 +1,254 @@ +-- Forge tutorial 6, "A light gun game": the kit's clip as the disc, a gun on the pointer, hit +-- boxes drawn on the timeline where the target and the hand are, ammo and a reload off the +-- picture, and a score. Starts from New game. +TUTORIAL = { number = 6, name = "06-gun", tag = "TUTORIAL6" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil +local fired = {} +local shot = false + +tutorialBegin(nil) + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function place(x, y) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + local entry = forgeRoom().entities[FORGE.selected] + + forgePress(entry.x, entry.y) + forgeDrag(x, y) + forgeRelease() +end + + +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +-- The cursor to a frame, by the leap and step keys, as a reader would. +local function cursorTo(frame) + while FORGE.cursor + 10 <= frame do + key(SCANCODE.RIGHTBRACKET) + end + while FORGE.cursor < frame do + key(SCANCODE.PERIOD) + end + while FORGE.cursor > frame do + key(SCANCODE.COMMA) + end +end + + +-- A key on the selected track at a frame, its box typed. +local function keyAt(frame, x, y, w, h) + cursorTo(frame) + key(SCANCODE.K) + form("x", x) + form("y", y) + form("w", w) + form("h", h) +end + + +local function step() + if stage == 1 then + key(SCANCODE.MAIN_1) + selectEntity("hero") + key(SCANCODE.DELETE) + selectEntity("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_2) + lightType("hero") + key(SCANCODE.DELETE) + lightType("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_3) + ruleGo(1, 0) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "Draw") + form("vars", "score=0, misses=0") + form("layers", "disc, overlay") + form("disc.file", "clip.mkv") + + elseif stage == 2 then + -- The gun, and the two things it can hit. + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", "gun") + form("look", "none") + form("vars", "ammo=6") + form("behaviours", "gun") + form("gun.player", 1) + form("gun.ammo", 6) + form("gun.reload", "offscreen") + key(SCANCODE.A) + form("name", "target") + form("look", "none") + form("vars", "health=1") + form("behaviours", "hitbox, health") + form("hitbox.track", "target") + form("health.max", 1) + key(SCANCODE.A) + form("name", "hand") + form("look", "none") + form("behaviours", "hitbox") + form("hitbox.track", "hand") + tutorialShot("types") + + elseif stage == 3 then + lightType("gun") + place(30, 450) + key(SCANCODE.MAIN_2) + lightType("target") + place(450, 210) + key(SCANCODE.MAIN_2) + lightType("hand") + place(600, 210) + + elseif stage == 4 then + -- The tracks: where the target is, frame by frame, and where the hand is. + key(SCANCODE.MAIN_4) + key(SCANCODE.N) + form("name", "target") + keyAt(150, 405, 165, 90, 90) + tutorialShot("firstKey") + + elseif stage == 5 then + keyAt(210, 405, 165, 90, 90) + key(SCANCODE.N) + form("name", "hand") + keyAt(220, 480, 157, 240, 105) + keyAt(260, 480, 157, 240, 105) + cursorTo(180) + tutorialShot("tracks") + + elseif stage == 6 then + key(SCANCODE.MAIN_3) + rule("The target is hit", "hit", { { "type", "target" } }) + part(SCANCODE.T, "addScore", { { "amount", 100 } }) + part(SCANCODE.T, "damage", { { "amount", 1 } }) + part(SCANCODE.T, "playSound", { { "file", "hit.wav" } }) + rule("The hand is hit", "hit", { { "type", "hand" } }) + part(SCANCODE.T, "addScore", { { "amount", -200 } }) + part(SCANCODE.T, "flash", { { "r", 255 }, { "g", 40 }, { "b", 40 }, { "seconds", 0.3 } }) + rule("A shot at nothing", "miss", {}) + part(SCANCODE.C, "test", { { "expr", "not event.offscreen" } }) + part(SCANCODE.T, "addVar", { { "name", "misses" }, { "amount", 1 } }) + clear("entity") + part(SCANCODE.T, "playSound", { { "file", "shot.wav" } }) + rule("Draw!", "frameReached", { { "frame", 150 } }) + part(SCANCODE.T, "say", { { "text", '"DRAW!"' }, { "seconds", 1 } }) + ruleGo(1, 1) + form("text", '"score " .. score .. " ammo " .. gun.ammo .. " misses " .. misses') + tutorialShot("rules") + + elseif stage == 7 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + end +end + + +-- A shot from the pointer at a frame of the disc, once. +local function fireAt(frame, x, y, now) + if (now >= frame) and not fired[frame] then + fired[frame] = true + authorSetPointer(1, x, y) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 7 then + stage = stage + 1 + step() + end + if game ~= nil then + local frame = discGetFrame() + + fireAt(170, 450, 210, frame) -- the target + fireAt(190, 100, 100, frame) -- the wall + fireAt(240, 620, 210, frame) -- the hand + if (frame >= 175) and not shot then + shot = true + tutorialShot("playing") + end + if frame >= 270 then + debugPrint(string.format("%s at frame %d: score %s, misses %s, ammo %s", TUTORIAL.tag, frame, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.misses), tostring(authorEntity("gun").vars.ammo))) + if (AUTHOR_VARS.score ~= -100) or (AUTHOR_VARS.misses ~= 1) then + debugPrint(TUTORIAL.tag .. " FAIL a hit on the target, a miss on the wall, and a hit on the hand should score -100 with one miss") + end + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene89.singe b/testScripts/scene89.singe new file mode 100644 index 000000000..6ccfe9615 --- /dev/null +++ b/testScripts/scene89.singe @@ -0,0 +1,353 @@ +-- Forge tutorial 7, "A point-and-click adventure": a painted yard, a walk area drawn on it, +-- a walker sent by clicks, hotspots with verbs, an inventory, a guard with a dialogue, and a +-- door to a second painted room. Starts from New game. +TUTORIAL = { number = 7, name = "07-adventure", tag = "TUTORIAL7" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil +local talked = false + +tutorialBegin(nil) + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function kitType(name, file, anchor) + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", name) + form("look", "sprite") + form("look.file", file) + if anchor then + form("look.anchor", anchor) + end +end + + +local function place(x, y) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + local entry = forgeRoom().entities[FORGE.selected] + + forgePress(entry.x, entry.y) + forgeDrag(x, y) + forgeRelease() +end + + +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +-- Up or down the dialogue outline to a row: a dialogue by name, a node by name, or a choice by +-- its text, as a reader would with the arrows. +local function dialogueGo(level, name) + local guard = 0 + + local function here() + for _, row in ipairs(forgeDialogueRows()) do + local selected = (row.dialogue == FORGE.dialogue) and (row.node == FORGE.dnode) and ((row.choice or 0) == (FORGE.dchoice or 0)) and ((row.act or 0) == (FORGE.dact or 0)) + + if selected and (row.level == level) then + if (level == "dialogue") and (row.dialogue == name) then + return true + elseif (level == "node") and (row.node == name) then + return true + elseif (level == "choice") and row.label:find(name, 1, true) then + return true + end + end + end + + return false + end + + -- Straight to the top, then down until the row is the one. + while not here() and (guard < 40) do + key(SCANCODE.UP) + guard = guard + 1 + if FORGE.dnode == nil and level == "dialogue" and FORGE.dialogue == name then + break + end + end + guard = 0 + while not here() and (guard < 40) do + key(SCANCODE.DOWN) + guard = guard + 1 + end + if not here() then + debugPrint(string.format("%s FAIL the outline never reached %s %s", TUTORIAL.tag, level, name)) + end +end + + +-- A walk area: V, a press on each corner, V again. +-- The panel steps aside on its own while the corners are pressed. +local function walkArea(corners) + key(SCANCODE.MAIN_1) + forgeSelect(nil) + key(SCANCODE.V) + for _, corner in ipairs(corners) do + forgePress(corner[1], corner[2]) + end + key(SCANCODE.V) +end + + +local function step() + if stage == 1 then + key(SCANCODE.MAIN_1) + selectEntity("hero") + key(SCANCODE.DELETE) + selectEntity("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_2) + lightType("hero") + key(SCANCODE.DELETE) + lightType("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_3) + ruleGo(1, 0) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "The Yard") + form("vars", "score=0, allowed=false") + form("verbs", "walk, look, take, use, open, talk") + form("layers", "overlay") + form("name", "yard") + form("depthSort", "true") + form("scaleBy", "300:0.6, 460:1.0") + + elseif stage == 2 then + -- The painting, the walk area on its floor, and the walker. + kitType("backdrop", "yard.png") + place(360, 240) + walkArea({ { 60, 320 }, { 660, 320 }, { 700, 460 }, { 40, 460 } }) + forgePanelShow(false) + tutorialShot("walkArea") + forgePanelShow(true) + kitType("hero", "hero.png", "feet") + form("look.frames", 8) + form("behaviours", "walker, frames") + form("walker.speed", 160) + form("frames.fps", 10) + form("frames.states", "idle=1-1, walk=2-5") + place(150, 440) + + elseif stage == 3 then + -- The things a verb can be used on. + kitType("door", "door.png", "feet") + form("behaviours", "hotspot") + form("hotspot.name", "door") + form("hotspot.walkX", 590) + form("hotspot.walkY", 330) + place(590, 300) + kitType("key", "key.png") + form("behaviours", "hotspot") + form("hotspot.name", "key") + form("hotspot.walkX", 200) + form("hotspot.walkY", 400) + place(200, 380) + kitType("guard", "hero.png", "feet") + form("look.frames", 8) + form("look.faces", "left") + form("behaviours", "hotspot") + form("hotspot.name", "guard") + form("hotspot.walkX", 480) + form("hotspot.walkY", 360) + place(520, 340) + tutorialShot("hotspots") + + elseif stage == 4 then + -- The guard's dialogue. + key(SCANCODE.MAIN_5) + key(SCANCODE.A) + form("name", "guard") + key(SCANCODE.DOWN) + form("who", "Guard") + form("text", "Nobody passes.") + key(SCANCODE.A) + form("text", "Why not?") + form("next", "why") + key(SCANCODE.A) + form("text", "Fine.") + dialogueGo("dialogue", "guard") + key(SCANCODE.A) + form("name", "why") + form("who", "Guard") + form("text", "The door is locked, and I have no key.") + key(SCANCODE.A) + form("text", "I have a key.") + form("when", 'has("key")') + form("next", "ok") + key(SCANCODE.T) + pick("setVar") + form("name", "allowed") + form("value", "true") + clear("entity") + dialogueGo("node", "why") + key(SCANCODE.A) + form("text", "I see.") + dialogueGo("dialogue", "guard") + key(SCANCODE.A) + form("name", "ok") + form("who", "Guard") + form("text", "Then go ahead.") + tutorialShot("dialogue") + + elseif stage == 5 then + key(SCANCODE.MAIN_3) + rule("A click on the floor walks there", "pressed", { { "switch", "SWITCH_BUTTON3" }, { "interrupt", "true" } }) + part(SCANCODE.T, "walkToPointer", { { "entity", "hero" }, { "player", 1 } }) + rule("Look at anything", "verb", { { "verb", "look" } }) + part(SCANCODE.T, "say", { { "text", '"It is a " .. event.target .. "."' }, { "seconds", 1 } }) + rule("Take the key", "verb", { { "verb", "take" }, { "target", "key" } }) + part(SCANCODE.T, "walkToHotspot", { { "entity", "hero" } }) + part(SCANCODE.T, "give", { { "item", "key" } }) + part(SCANCODE.T, "addScore", { { "amount", 1 } }) + part(SCANCODE.T, "destroy", {}) + rule("Talk to the guard", "verb", { { "verb", "talk" }, { "target", "guard" } }) + part(SCANCODE.T, "walkToHotspot", { { "entity", "hero" } }) + part(SCANCODE.T, "face", { { "entity", "hero" }, { "target", "self" } }) + part(SCANCODE.T, "talk", { { "dialogue", "guard" } }) + tutorialShot("verbRules") + + elseif stage == 6 then + rule("The door, while the guard objects", "verb", { { "verb", "open" }, { "target", "door" } }) + part(SCANCODE.C, "test", { { "expr", "not allowed" } }) + part(SCANCODE.T, "say", { { "text", '"The guard shakes his head."' }, { "seconds", 1 } }) + rule("The door opens", "verb", { { "verb", "open" }, { "target", "door" }, { "controls", "false" } }) + part(SCANCODE.C, "test", { { "expr", "allowed" } }) + part(SCANCODE.T, "walkToHotspot", { { "entity", "hero" } }) + part(SCANCODE.T, "playSound", { { "file", "door.wav" } }) + part(SCANCODE.T, "fade", { { "seconds", 0.5 }, { "out", "true" } }) + part(SCANCODE.T, "goTo", { { "room", "hall" }, { "entity", "hero" }, { "x", 120 }, { "y", 420 } }) + part(SCANCODE.T, "fade", { { "seconds", 0.5 }, { "out", "false" } }) + part(SCANCODE.T, "addScore", { { "amount", 1 } }) + rule("Anything else", "verb", {}) + part(SCANCODE.T, "say", { { "text", '"That does not work."' }, { "seconds", 1 } }) + rule("The verb keys", "pressed", { { "key", "L" } }) + part(SCANCODE.T, "setVerb", { { "verb", "look" } }) + rule("Take", "pressed", { { "key", "T" } }) + part(SCANCODE.T, "setVerb", { { "verb", "take" } }) + rule("Open", "pressed", { { "key", "O" } }) + part(SCANCODE.T, "setVerb", { { "verb", "open" } }) + rule("Talk", "pressed", { { "key", "K" } }) + part(SCANCODE.T, "setVerb", { { "verb", "talk" } }) + ruleGo(1, 1) + form("text", '(sentence or "") .. " score " .. score') + + elseif stage == 7 then + -- The hall: a second painting, its floor, and the readout. + key(SCANCODE.MAIN_1) + key(SCANCODE.N) + form("name", "hall") + form("depthSort", "true") + key(SCANCODE.MAIN_2) + lightType("backdrop") + place(360, 240) + walkArea({ { 40, 300 }, { 680, 300 }, { 700, 460 }, { 40, 460 } }) + key(SCANCODE.MAIN_1) + selectEntity("backdrop") + form("type", "backdrop") + key(SCANCODE.MAIN_2) + lightType("readout") + place(12, 12) + tutorialShot("hall") + + elseif stage == 8 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + + elseif stage == 10 then + -- Take the key: the take verb on the key hotspot. + authorSetVerb("take") + authorSetPointer(1, 200, 380) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + elseif stage == 16 then + tutorialShot("playing") + local hx, hy = authorPosition(authorEntity("hero")) + + debugPrint(string.format("%s the key is %s, the score %s, the hero at %.0f, %.0f", TUTORIAL.tag, authorHas("key") and "taken" or "still there", tostring(AUTHOR_VARS.score), hx, hy)) + if not authorHas("key") then + debugPrint(TUTORIAL.tag .. " FAIL the take verb on the key should walk there and take it") + end + elseif stage == 17 then + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 17 then + stage = stage + 1 + step() + end + if game ~= nil then + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene90.singe b/testScripts/scene90.singe new file mode 100644 index 000000000..6a59bcfc6 --- /dev/null +++ b/testScripts/scene90.singe @@ -0,0 +1,246 @@ +-- Forge tutorial 8, "Into 3D": a scene3d layer, meshes for floors and a ledge, the kit's fox on +-- the character controller with a camera that follows, the 3D viewport, and a prize that is a +-- trigger. Starts from New game. +TUTORIAL = { number = 8, name = "08-scene", tag = "TUTORIAL8" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil + +tutorialBegin(nil) + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +-- A type with a mesh look: a box or a sphere of a size and a colour. +local function meshType(name, shape, w, h, d, r, g, b) + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", name) + form("look", "mesh") + form("look.shape", shape) + form("look.w", w) + if h then + form("look.h", h) + end + if d then + form("look.d", d) + end + form("look.r", r) + form("look.g", g) + form("look.b", b) +end + + +-- An entity of the lit type, added and its place typed: in a scene, positions are world units. +local function placeAt(id, x, y, z) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + form("id", id) + form("x", x) + form("y", y) + form("z", z) +end + + +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +local function step() + if stage == 1 then + key(SCANCODE.MAIN_1) + selectEntity("hero") + key(SCANCODE.DELETE) + selectEntity("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_2) + lightType("hero") + key(SCANCODE.DELETE) + lightType("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_3) + ruleGo(1, 0) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "Field") + form("layers", "scene3d, overlay") + form("scene3d.fov", 55) + tutorialShot("scene") + + elseif stage == 2 then + meshType("floor", "box", 30, 0.4, 12, 90, 110, 90) + form("behaviours", "solid") + meshType("ledge", "box", 4, 0.4, 4, 110, 90, 70) + form("behaviours", "solid") + meshType("prize", "sphere", 0.6, nil, nil, 255, 200, 60) + form("behaviours", "trigger") + tutorialShot("meshes") + + elseif stage == 3 then + key(SCANCODE.A) + form("name", "hero") + form("look", "model") + form("look.file", "fox.glb") + form("look.scale", 0.008) + form("look.w", 0.6) + form("look.h", 0.9) + form("look.d", 0.6) + form("behaviours", "keys, character, animator") + form("keys.left", "LEFT") + form("keys.right", "RIGHT") + form("keys.up", "UP") + form("keys.down", "DOWN") + form("character.speed", 4) + form("character.jump", 6.5) + form("character.radius", 0.3) + form("character.height", 0.9) + form("animator.idle", "Survey") + form("animator.walk", "Walk") + key(SCANCODE.A) + form("name", "camera") + form("look", "none") + form("behaviours", "camera") + form("camera.mode", "follow") + form("camera.target", "hero") + form("camera.x", 0) + form("camera.y", 3) + form("camera.z", 7) + form("camera.lookY", 0.6) + form("camera.lag", 0.2) + tutorialShot("hero") + + elseif stage == 4 then + lightType("floor") + placeAt("floor", 0, -0.2, 0) + key(SCANCODE.MAIN_2) + lightType("ledge") + placeAt("ledge", 6, 1.2, 0) + key(SCANCODE.MAIN_2) + lightType("prize") + placeAt("prize", 6, 2, 0) + key(SCANCODE.MAIN_2) + lightType("hero") + placeAt("hero", -4, 0.5, 0) + key(SCANCODE.MAIN_2) + lightType("camera") + placeAt("camera", -4, 3, 7) + -- The viewport orbits what is selected: a few steps round, for the picture. + selectEntity("hero") + key(SCANCODE.L) + key(SCANCODE.L) + key(SCANCODE.Y) + tutorialShot("viewport") + + elseif stage == 5 then + key(SCANCODE.MAIN_3) + rule("Walking is a state the animator plays", "frame", { { "each", "hero" } }) + part(SCANCODE.C, "test", { { "expr", "self.dx != 0 or self.dy != 0" } }) + part(SCANCODE.T, "setState", { { "state", "walk" } }) + rule("Standing still", "frame", { { "each", "hero" } }) + part(SCANCODE.C, "test", { { "expr", "self.dx == 0 and self.dy == 0" } }) + part(SCANCODE.T, "setState", { { "state", "idle" } }) + rule("Jump, with ground underfoot", "frame", {}) + part(SCANCODE.C, "keyPressed", { { "key", "SPACE" } }) + part(SCANCODE.C, "onGround", { { "entity", "hero" } }) + part(SCANCODE.T, "jump", { { "entity", "hero" } }) + rule("The prize is taken", "enter", { { "a", "prize" }, { "b", "hero" } }) + part(SCANCODE.T, "destroy", {}) + part(SCANCODE.T, "addScore", { { "amount", 100 } }) + part(SCANCODE.T, "playSound", { { "file", "coin.wav" } }) + rule("Fallen off the world", "frame", {}) + part(SCANCODE.C, "test", { { "expr", "hero.y < -5" } }) + part(SCANCODE.T, "moveTo", { { "entity", "hero" }, { "x", -4 }, { "y", 0.5 }, { "z", 0 } }) + tutorialShot("rules") + + elseif stage == 6 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + authorKeyDown(0, SCANCODE.RIGHT.value) + + elseif stage == 14 then + tutorialShot("playing") + local hero = authorEntity("hero") + local x, y, z = authorPosition(hero) + + debugPrint(string.format("%s the fox is at %.1f, %.1f, %.1f in state %s", TUTORIAL.tag, x, y, z, tostring(hero.vars.state))) + if (x < -3) or (hero.vars.state ~= "walk") then + debugPrint(TUTORIAL.tag .. " FAIL holding RIGHT should walk the fox along") + end + elseif stage == 15 then + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 15 then + stage = stage + 1 + step() + end + if game ~= nil then + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene91.singe b/testScripts/scene91.singe new file mode 100644 index 000000000..ab9e2f791 --- /dev/null +++ b/testScripts/scene91.singe @@ -0,0 +1,308 @@ +-- Forge tutorial 9, "A rail shooter": a camera on a rail with stops, a painted backdrop and an +-- occluder pillar that is only in the picture, zombies that spawn at each stop and walk to the +-- camera, a gun on the pointer, ragdolls, and the rail moving on. Starts from New game. +TUTORIAL = { number = 9, name = "09-rail", tag = "TUTORIAL9" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil +local fired = 0 +local shot = false + +tutorialBegin(nil) + + +local function lightType(name) + local names = {} + local from, to = 1, 1 + + for typeName in pairs(FORGE.game.types) do + names[#names + 1] = typeName + end + table.sort(names) + for at, typeName in ipairs(names) do + if typeName == FORGE.typeName then + from = at + end + if typeName == name then + to = at + end + end + for _ = 1, math.abs(to - from) do + key((to > from) and SCANCODE.DOWN or SCANCODE.UP) + end +end + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function meshType(name, w, h, d, r, g, b) + key(SCANCODE.MAIN_2) + key(SCANCODE.A) + form("name", name) + form("look", "mesh") + form("look.shape", "box") + form("look.w", w) + form("look.h", h) + form("look.d", d) + if r then + form("look.r", r) + form("look.g", g) + form("look.b", b) + end +end + + +local function placeAt(id, x, y, z) + key(SCANCODE.MAIN_1) + key(SCANCODE.A) + form("id", id) + form("x", x) + form("y", y) + form("z", z) +end + + +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +-- A rail point: K puts one where the editor camera stands, and the form moves it. +local function railPoint(at, x, y, z, lookX, lookY, lookZ, stop) + key(SCANCODE.K) + form("at", at) + form("x", x) + form("y", y) + form("z", z) + form("lookX", lookX) + form("lookY", lookY) + form("lookZ", lookZ) + if stop then + form("stop", stop) + end +end + + +local function step() + if stage == 1 then + key(SCANCODE.MAIN_1) + selectEntity("hero") + key(SCANCODE.DELETE) + selectEntity("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_2) + lightType("hero") + key(SCANCODE.DELETE) + lightType("ground") + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_3) + ruleGo(1, 0) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.DELETE) + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "The Dead Yard") + form("vars", "score=0, health=3") + form("layers", "scene3d, overlay") + form("scene3d.fov", 60) + + elseif stage == 2 then + -- The room: a floor, the yard as a painting at the back, and the pillar in it as an + -- occluder, so a zombie walks behind a pillar that is only paint. + meshType("floor", 40, 0.2, 60, 70, 75, 90) + form("behaviours", "solid") + meshType("painting", 24, 16, 0.2, 255, 255, 255) + form("look.texture", "yard.png") + form("look.unlit", "true") + meshType("pillar", 1.2, 8, 1.2) + form("look.occluder", "true") + tutorialShot("room") + + elseif stage == 3 then + key(SCANCODE.A) + form("name", "camera") + form("look", "none") + form("behaviours", "camera") + form("camera.mode", "rail") + form("camera.track", "rail") + key(SCANCODE.A) + form("name", "gun") + form("look", "none") + form("vars", "ammo=6") + form("behaviours", "gun") + form("gun.player", 1) + form("gun.ammo", 6) + form("gun.reload", "offscreen") + key(SCANCODE.A) + form("name", "zombie") + form("look", "model") + form("look.file", "fox.glb") + form("look.scale", 0.01) + form("look.w", 0.8) + form("look.h", 0.9) + form("look.d", 1.4) + form("vars", "health=2") + form("behaviours", "health, seek, animator, target, timer") + form("health.max", 2) + form("health.ragdoll", "true") + form("health.linger", 2) + form("seek.target", "camera") + form("seek.speed", 1.6) + form("seek.stopAt", 2.2) + form("animator.idle", "Survey") + form("animator.walk", "Walk") + form("animator.attack", "Run") + form("timer.name", "bite") + form("timer.after", 1.5) + form("timer.start", "false") + tutorialShot("zombie") + + elseif stage == 4 then + lightType("floor") + placeAt("floor", 0, -0.1, -20) + key(SCANCODE.MAIN_2) + lightType("painting") + placeAt("painting", 0, 6, -30) + key(SCANCODE.MAIN_2) + lightType("pillar") + placeAt("pillar", 0, 4, -22) + key(SCANCODE.MAIN_2) + lightType("camera") + placeAt("camera", 0, 1.6, 0) + key(SCANCODE.MAIN_2) + lightType("gun") + placeAt("gun", 0, 0, 0) + selectEntity("pillar") + tutorialShot("placed") + + elseif stage == 5 then + -- The rail: a track of points in time, two of them stops. + key(SCANCODE.MAIN_4) + key(SCANCODE.N) + form("name", "rail") + railPoint(0, 0, 1.6, 0, 0, 1.2, -10) + railPoint(4, 0, 1.6, -6, 0, 1.2, -16, "gate") + railPoint(10, 2, 1.6, -14, 2, 1.2, -24, "pillar") + railPoint(14, 2, 1.6, -18, 2, 1.2, -28) + tutorialShot("rail") + + elseif stage == 6 then + key(SCANCODE.MAIN_3) + rule("The gate: two come out of the dark", "stopped", { { "name", "gate" } }) + part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", -2 }, { "y", 0 }, { "z", -14 } }) + part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", 2 }, { "y", 0 }, { "z", -15 } }) + rule("The pillar: three more, one from behind it", "stopped", { { "name", "pillar" } }) + part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", -1 }, { "y", 0 }, { "z", -23 } }) + part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", 0 }, { "y", 0 }, { "z", -26 } }) + part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", 4 }, { "y", 0 }, { "z", -24 } }) + rule("A zombie walks the moment it is made", "spawn", { { "type", "zombie" } }) + part(SCANCODE.T, "setState", { { "state", "walk" } }) + rule("A shot lands", "hit", { { "type", "zombie" } }) + part(SCANCODE.T, "damage", { { "amount", 1 } }) + part(SCANCODE.T, "addScore", { { "amount", 100 } }) + part(SCANCODE.T, "playSound", { { "file", "hit.wav" } }) + tutorialShot("hitRule") + + elseif stage == 7 then + rule("It reaches the camera and rears up", "arrived", { { "type", "zombie" } }) + part(SCANCODE.T, "setState", { { "state", "attack" } }) + part(SCANCODE.T, "timerStart", { { "name", "bite" }, { "after", 1.5 } }) + rule("The bite lands unless it was killed in time", "timer", { { "type", "zombie" }, { "name", "bite" } }) + part(SCANCODE.C, "inState", { { "state", "attack" } }) + part(SCANCODE.T, "addVar", { { "name", "health" }, { "amount", -1 } }) + clear("entity") + part(SCANCODE.T, "flash", { { "r", 255 }, { "g", 40 }, { "b", 40 }, { "seconds", 0.3 } }) + part(SCANCODE.T, "shake", { { "amount", 0.3 }, { "seconds", 0.4 } }) + part(SCANCODE.T, "timerStart", { { "name", "bite" }, { "after", 1.5 } }) + rule("The stop is clear: move on", "frame", {}) + part(SCANCODE.C, "waiting", { { "entity", "camera" } }) + part(SCANCODE.C, "test", { { "expr", 'count("zombie") == 0' } }) + part(SCANCODE.T, "pathNext", { { "entity", "camera" } }) + rule("Out of health", "frame", {}) + part(SCANCODE.C, "test", { { "expr", "health <= 0" } }) + part(SCANCODE.C, "once", { { "tag", "over" }, { "scope", "game" } }) + part(SCANCODE.T, "say", { { "text", '"YOU ARE DEAD"' }, { "seconds", 2 } }) + part(SCANCODE.T, "gameOver", {}) + rule("The end of the rail", "railEnd", {}) + part(SCANCODE.T, "say", { { "text", '"STAGE CLEAR"' }, { "seconds", 2 } }) + ruleGo(1, 1) + form("text", '"score " .. score .. " health " .. health .. " ammo " .. gun.ammo') + tutorialShot("rules") + + elseif stage == 8 then + key(SCANCODE.S) + local built = tutorialEnd() + local mine = onOverlayUpdate + + dofile(built) + game = onOverlayUpdate + onOverlayUpdate = mine + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 8 then + stage = stage + 1 + step() + end + if game ~= nil then + local t = authorTime() + + -- At the gate stop, shoot the first zombie through the middle of the picture. + if (t > 5) and (fired < 2) and (authorCount("zombie") > 0) then + local zombie = authorEntity and nil + local sx, sy = sceneProject(-2, 0.6, -14) + + fired = fired + 1 + authorSetPointer(1, sx, sy) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + end + if (t > 6) and not shot then + shot = true + tutorialShot("playing") + end + if t > 8 then + debugPrint(string.format("%s after 8 seconds: score %s, health %s, %d zombies, camera %s", TUTORIAL.tag, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.health), authorCount("zombie"), authorWaiting(authorEntity("camera")) and "waiting" or "riding")) + if authorCount("zombie") == 0 then + debugPrint(TUTORIAL.tag .. " FAIL the gate stop should have spawned zombies") + end + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene92.singe b/testScripts/scene92.singe new file mode 100644 index 000000000..d135f8698 --- /dev/null +++ b/testScripts/scene92.singe @@ -0,0 +1,175 @@ +-- Forge tutorial 10, "Releasing your game": the platformer with the arcade's plumbing -- lives, +-- a coin slot, a continue, the bezel, the score sent to the board -- released to a folder of its +-- own with everything it needs. Continues from tutorial 3. +TUTORIAL = { number = 10, name = "10-release", tag = "TUTORIAL10" } +dofile("testScripts/tutorialDrive.singe") + +local frames = 0 +local stage = 0 +local game = nil +local folder = nil + +tutorialBegin("03-sprites") + + +local function ruleGo(index, part) + local guard = 0 + + while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do + local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) + + key(past and SCANCODE.UP or SCANCODE.DOWN) + guard = guard + 1 + end +end + + +local function rule(note, on, filters) + key(SCANCODE.N) + form("note", note) + key(SCANCODE.E) + pick(on) + for _, pair in ipairs(filters or {}) do + form(pair[1], pair[2]) + end +end + + +local function part(partKey, name, fields) + key(partKey) + pick(name) + for _, pair in ipairs(fields or {}) do + form(pair[1], pair[2]) + end +end + + +local function step() + if stage == 1 then + -- The game's form: a title of its own, lives and credits, and the bezel around the picture. + key(SCANCODE.MAIN_1) + forgeSelect(nil) + form("title", "Coin Run") + form("vars", "score=0, lives=3, credits=0") + form("layers", "world2d, bezel") + tutorialShot("bezel") + + elseif stage == 2 then + -- Spikes cost a life now, and a coin buys a continue. + key(SCANCODE.MAIN_3) + ruleGo(6, 0) + key(SCANCODE.DOWN) + key(SCANCODE.DOWN) + key(SCANCODE.DOWN) + part(SCANCODE.T, "addVar", { { "name", "lives" }, { "amount", -1 } }) + clear("entity") + rule("Out of lives", "frame", {}) + part(SCANCODE.C, "test", { { "expr", "lives <= 0" } }) + part(SCANCODE.C, "once", { { "tag", "over" }, { "scope", "game" } }) + part(SCANCODE.T, "submitScore", { { "board", "default" } }) + part(SCANCODE.T, "say", { { "text", '"GAME OVER"' }, { "seconds", 2 } }) + part(SCANCODE.T, "gameOver", {}) + rule("A coin", "pressed", { { "switch", "SWITCH_COIN1" } }) + part(SCANCODE.T, "credit", {}) + rule("Continue", "pressed", { { "switch", "SWITCH_START1" } }) + part(SCANCODE.C, "test", { { "expr", "credits > 0 and lives <= 0" } }) + part(SCANCODE.T, "addVar", { { "name", "credits" }, { "amount", -1 } }) + clear("entity") + part(SCANCODE.T, "setVar", { { "name", "lives" }, { "value", "3" } }) + clear("entity") + part(SCANCODE.T, "restart", {}) + ruleGo(4, 1) + form("text", '"score " .. score .. " lives " .. lives') + tutorialShot("arcadeRules") + + elseif stage == 3 then + key(SCANCODE.S) + -- X releases: the compiled game, the runtime, the description, a games.dat, and every + -- file the description names, in a folder of the game's own. + key(SCANCODE.X) + folder = forgeReleaseFolder() + tutorialShot("released") + local files = {} + + for name in lfs.dir(folder) do + if name:sub(1, 1) ~= "." then + files[#files + 1] = name + end + end + table.sort(files) + debugPrint(TUTORIAL.tag .. " released to " .. folder .. ": " .. table.concat(files, ", ")) + for _, want in ipairs({ "CoinRun.singe", "CoinRun.game", "Author.singe", "games.dat", "Forge" }) do + if lfs.attributes(folder .. "/" .. want) == nil then + debugPrint(TUTORIAL.tag .. " FAIL the release lacks " .. want) + end + end + if lfs.attributes(folder .. "/Forge/kit/hero.png", "mode") ~= "file" then + debugPrint(TUTORIAL.tag .. " FAIL the release should carry the kit files it names") + end + + elseif stage == 4 then + -- Packed, as the tutorial's last step says, when the harness says where the engine is: a + -- scene cannot know its own executable, so SINGE_BIN carries it, and without it the step + -- is reported as not tried rather than failed. + local bin = os.getenv("SINGE_BIN") + + if bin then + local packed = TUTORIAL_OUT .. "CoinRun.game" + + os.remove(packed) + os.execute(string.format('"%s" --pack "%s" "%s" > "%s" 2>&1', bin, folder, packed, TUTORIAL_OUT .. "pack.log")) + -- What the packer said is the measure: a .game is a database, and the engine's own + -- file lookups treat one as a game rather than as bytes, so its size is not readable + -- from inside a running script. + local log = io.open(TUTORIAL_OUT .. "pack.log", "r") + local size = 0 + + if log then + for line in log:lines() do + debugPrint(TUTORIAL.tag .. " pack: " .. line) + size = tonumber(line:match("Packed %d+ files %((%d+) bytes%)")) or size + end + log:close() + end + debugPrint(string.format("%s packed the release into %s: %d bytes", TUTORIAL.tag, packed, size)) + if size < 1000 then + debugPrint(TUTORIAL.tag .. " FAIL --pack should have written a .game") + end + else + debugPrint(TUTORIAL.tag .. " --pack not tried: SINGE_BIN is not set") + end + tutorialEnd() + -- The release runs from its own folder, finding its runtime and its files beside itself. + local mine = onOverlayUpdate + + dofile(folder .. "/CoinRun.singe") + game = onOverlayUpdate + onOverlayUpdate = mine + authorKeyDown(0, SCANCODE.RIGHT.value) + + elseif stage == 8 then + tutorialShot("playing") + debugPrint(string.format("%s the release plays from %s: score %s, lives %s, runtime at %s", TUTORIAL.tag, folder, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.lives), tostring(AUTHOR_DIR))) + if AUTHOR_DIR ~= folder .. "/" then + debugPrint(TUTORIAL.tag .. " FAIL the release should know its own folder") + end + elseif stage == 9 then + debugPrint(TUTORIAL.tag .. " RESULT done") + singeQuit() + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 12 == 0 and stage < 9 then + stage = stage + 1 + step() + end + if game ~= nil then + return game() + end + forgeDraw(nil, nil) + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene93.singe b/testScripts/scene93.singe new file mode 100644 index 000000000..013a34ca2 --- /dev/null +++ b/testScripts/scene93.singe @@ -0,0 +1,51 @@ +-- Overlay layers round guiDraw: what a script draws before a guiDraw is under the document and +-- what it draws after is over it, so a gun's sight or an editor's pointer is never hidden by a +-- HUD. Forge's own chrome, an opaque panel down the left, is the document: a red box is drawn +-- before it and a yellow cross after it, both across the panel's edge, and the shot shows the box +-- cut off by the panel and the cross whole over it. Then a second copy of the panel drawn small +-- after the cross, with a blue box drawn after that, three layers deep. +local font = fontLoad("Singe/FreeSansBold.ttf", 16) +local gui = guiNew(720, 480) +local doc = guiLoad(gui, "Forge/Forge.rml") +local gui2 = guiNew(720, 480) +local doc2 = guiLoad(gui2, "Forge/Forge.rml") +local frames = 0 + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + + +function onOverlayUpdate() + frames = frames + 1 + colorBackground(20, 20, 30, 255) + overlayClear() + -- Under the panel: a red box across its right edge (the panel is 190 wide, and its grip + -- and title are the opaque part of an empty one, across the top). + colorForeground(230, 60, 60, 255) + for y = 40, 80 do + overlayLine(100, y, 300, y) + end + guiDraw(gui) + -- Over it: a yellow cross on the panel's edge, and a line of text. + colorForeground(255, 220, 60, 255) + overlayLine(150, 60, 230, 60) + overlayLine(190, 20, 190, 100) + fontPrint(220, 300, "drawn after guiDraw: over the panel") + -- A second panel, drawn small over the cross's layer, and a blue box over its grip. + guiDraw(gui2, 400, 150, 240, 160) + colorForeground(80, 120, 255, 255) + for y = 150, 170 do + overlayLine(380, y, 480, y) + end + if frames == 10 then + singeScreenshot("layers") + debugPrint("LAYERS two panels queued, each with what follows it on a layer of its own; see the shot") + debugPrint("LAYERS RESULT done") + end + if frames > 14 then + singeQuit() + end + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene94.singe b/testScripts/scene94.singe new file mode 100644 index 000000000..da1be66b2 --- /dev/null +++ b/testScripts/scene94.singe @@ -0,0 +1,54 @@ +-- Forge: a waves track by time, and soundDone. Three rocks come from the left and right spots +-- in turn a fifth of a second apart from half a second in, two more at a point from a second and +-- a half, and the bell's clip, played as it spawned, reports its end. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/waves.game", singeGetDataPath() .. "waves.singe")) + +local gameUpdate = onOverlayUpdate +local shot = false +local checked = false +local began = os.time() +local PATIENCE = 10 -- Real seconds to wait for the bell: the mixer runs on the real clock, whatever the frame clock does. + + +function onOverlayUpdate() + local t = authorTime() + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("waves %.1fs: %d rocks, made %d, rung %d", t, authorCount("rock"), AUTHOR_VARS.made, AUTHOR_VARS.rung)) + if (t > 1.2) and not shot then + shot = true + singeScreenshot() + end + if (t > 3) and not checked then + local rocks = authorEach("rock") + local xs = {} + + for _, rock in ipairs(rocks) do + local x = authorPosition(rock) + + xs[#xs + 1] = math.floor(x) + end + debugPrint(string.format("WAVES RESULT rocks=%d made=%d rung=%d at=%s after=%.1fs", #rocks, AUTHOR_VARS.made, AUTHOR_VARS.rung, table.concat(xs, ","), t)) + if #rocks ~= 5 then debugPrint("WAVES FAIL five rocks should have come, not " .. #rocks) end + if AUTHOR_VARS.made ~= 5 then debugPrint("WAVES FAIL the spawn rule saw " .. AUTHOR_VARS.made .. " rocks, not 5") end + if (xs[1] ~= 60) or (xs[2] ~= 660) or (xs[3] ~= 60) then debugPrint("WAVES FAIL the first wave did not come from the spots in turn") end + if (xs[4] ~= 360) or (xs[5] ~= 360) then debugPrint("WAVES FAIL the second wave did not come at its point") end + checked = true + end + -- The bell's clip ends on the mixer's own clock, which a deterministic run's frames outpace. + if checked and ((AUTHOR_VARS.rung >= 1) or (os.time() - began >= PATIENCE)) then + debugPrint(string.format("WAVES RESULT rung=%d after %d real seconds", AUTHOR_VARS.rung, os.time() - began)) + if AUTHOR_VARS.rung < 1 then debugPrint("WAVES FAIL soundDone never came for the bell") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene95.singe b/testScripts/scene95.singe new file mode 100644 index 000000000..6f45f3bf0 --- /dev/null +++ b/testScripts/scene95.singe @@ -0,0 +1,56 @@ +-- Forge's editor makes a waves track by keys: W adds it, K a wave at the cursor, and the wave's +-- fields are typed like any other. The built game carries the track. +TUTORIAL = { number = 0, name = "00-waves", tag = "WAVESED" } +dofile("testScripts/tutorialDrive.singe") +tutorialBegin(nil) + +local frames = 0 + +function onOverlayUpdate() + frames = frames + 1 + if frames == 6 then + panel("tracks") + key(SCANCODE.W) + key(SCANCODE.K) + form("type", "hero") + form("count", 3) + form("every", 0.2) + form("from", "hero") + local track = forgeRoom().tracks[1] + local wave = track and track.spawns and track.spawns[1] + + if wave and (wave.type == "hero") and (wave.count == 3) and (wave.every == 0.2) and (wave.from == "hero") and (wave.at == 0) then + debugPrint("WAVESED the keys made a wave: at " .. wave.at .. ", " .. wave.count .. " x " .. wave.type .. " from " .. wave.from .. " every " .. wave.every) + else + debugPrint("WAVESED FAIL the wave is " .. tostring(wave and (tostring(wave.at) .. " " .. tostring(wave.type) .. " " .. tostring(wave.count) .. " " .. tostring(wave.every) .. " " .. tostring(wave.from)))) + end + -- Keyed by a stop instead: the wave's at becomes a name. + key(SCANCODE.UP) + form("key", "stop") + key(SCANCODE.DOWN) + form("at", "hall") + if (track.key == "stop") and (wave.at == "hall") then + debugPrint("WAVESED keyed by stops: the wave waits for " .. wave.at) + else + debugPrint("WAVESED FAIL keying by stops gave key " .. tostring(track.key) .. ", at " .. tostring(wave.at)) + end + singeScreenshot("wavesTrack") + elseif frames == 8 then + forgeSave() + local built = forgeBuild(TUTORIAL_OUT .. "waves.singe") + local file = built and io.open(TUTORIAL_OUT .. "waves.singe", "r") + local text = file and file:read("a") or "" + + if file then + file:close() + end + if text:find("spawns", 1, true) and text:find("hall", 1, true) then + debugPrint("WAVESED the built game carries the waves track") + else + debugPrint("WAVESED FAIL the built game has no waves track") + end + singeQuit() + end + forgeDraw(nil, nil) + return OVERLAY_UPDATED +end diff --git a/testScripts/tutorialDrive.singe b/testScripts/tutorialDrive.singe new file mode 100644 index 000000000..e70a97ed4 --- /dev/null +++ b/testScripts/tutorialDrive.singe @@ -0,0 +1,186 @@ +-- What the tutorial scenes share: the keys a reader presses, pressed the same way, and the two +-- checks every tutorial ends on. A tutorial in docs/Forge.adoc names keys; its scene presses +-- them through here, so the text and the test cannot drift apart without the sweep saying so. +-- +-- A scene sets TUTORIAL (its number and name) before loading this, and calls tutorialBegin to +-- open a description, tutorialShot to take a named picture, and tutorialEnd to check the result +-- against the shipped description and run the built game. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 15) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +TUTORIAL_OUT = singeGetDataPath() + + +-- The engine hands onKeyPressed two integers; so does this. +function key(code, character) + forgeKey(character or 0, code.value) +end + + +-- Types text as a person would, one character at a time. +function typeText(text) + for at = 1, #text do + forgeKey(string.byte(text, at), 0) + end +end + + +-- ENTER until the field is the one being edited. Answers whether it was reached. +local function reach(field) + local guard = 0 + + while (FORGE.lastField ~= field) and (guard < 40) do + key(SCANCODE.RETURN) + guard = guard + 1 + end + if FORGE.lastField ~= field then + debugPrint(string.format("%s FAIL the form never reached %s", TUTORIAL.tag, field)) + return false + end + + return true +end + + +-- ESC out of the form: off a list first, then off the field it left being typed. +local function leave() + if FORGE.picker ~= nil then + key(SCANCODE.ESCAPE) + end + if FORGE.editing ~= nil then + key(SCANCODE.ESCAPE) + end +end + + +-- Fills in one field of the form ENTER walks: ENTER until the field is the one being edited, +-- type the value, ENTER to keep it, ESC to leave the form. Exactly the keys the text names. +-- A field with a list narrows it to the value and takes it, which moves on to the next field. +function form(field, value) + if not reach(field) then + return + end + if FORGE.picker ~= nil then + pick(value) + else + typeText(tostring(value)) + key(SCANCODE.RETURN) + end + leave() +end + + +-- Empties one field of the form: ENTER until it is the one being edited, DELETE, ESC. +function clear(field) + if not reach(field) then + return + end + if FORGE.picker ~= nil then + pick("(none)") + else + key(SCANCODE.DELETE) + end + leave() +end + + +-- Narrows the picker by typing and takes the first match. +function pick(name) + typeText(name) + key(SCANCODE.RETURN) +end + + +-- Selects the entity with an id, in the entities panel. +function selectEntity(id) + for index, entry in ipairs(forgeRoom().entities) do + if entry.id == id then + forgeSelect(index) + return + end + end + debugPrint(string.format("%s FAIL no entity called %s", TUTORIAL.tag, id)) +end + + +-- Puts the panel on a mode, by TAB as a reader would. +function panel(mode) + local guard = 0 + + while (FORGE.mode ~= mode) and (guard < 6) do + key(SCANCODE.TAB) + guard = guard + 1 + end +end + + +-- A picture for the book, drawn now and named. +function tutorialShot(name) + singeScreenshot(string.format("%02d-%s", TUTORIAL.number, name)) +end + + +-- Opens the tutorial's working copy: a fresh New game, or the previous tutorial's shipped +-- description, so each starts where the last left off. +function tutorialBegin(fromShipped) + local work = TUTORIAL_OUT .. TUTORIAL.name .. ".game" + + if fromShipped then + if not authorCopy("Forge/tutorials/" .. fromShipped .. ".game", work) then + debugPrint(TUTORIAL.tag .. " FAIL could not copy " .. fromShipped) + singeQuit() + end + else + authorSave(forgeTemplate(), work) + end + if not forgeBegin(work) then + debugPrint(TUTORIAL.tag .. " FAIL could not open " .. work) + singeQuit() + end + + return work +end + + +-- The two checks: what the keys made equals the shipped description, compiled; and the game it +-- makes runs. A shipped description that is not there yet is written, so a new tutorial's scene +-- produces the file the book ships, and the next run checks against it. +function tutorialEnd() + local shipped = "Forge/tutorials/" .. TUTORIAL.name .. ".game" + local built + + forgeSave() + -- The compiled header names the description's file, which a release has set and a shipped + -- copy has not; the comparison is of the game, not of where it came from. + FORGE.game.source = nil + if lfs.attributes(shipped, "mode") ~= "file" then + authorCopy(FORGE.path, TUTORIAL_OUT .. TUTORIAL.name .. ".shipped.game") + debugPrint(TUTORIAL.tag .. " WROTE " .. TUTORIAL_OUT .. TUTORIAL.name .. ".shipped.game -- copy it to " .. shipped) + else + local mine = authorCompile(FORGE.game) + local theirs = authorCompile(authorLoad(shipped)) + + if mine == theirs then + debugPrint(TUTORIAL.tag .. " the keys made the shipped description") + else + local out = assert(io.open(TUTORIAL_OUT .. TUTORIAL.name .. ".mine.singe", "w")) + + out:write(mine) + out:close() + out = assert(io.open(TUTORIAL_OUT .. TUTORIAL.name .. ".theirs.singe", "w")) + out:write(theirs) + out:close() + debugPrint(TUTORIAL.tag .. " FAIL the keys made something else; see " .. TUTORIAL_OUT .. TUTORIAL.name .. ".mine.singe") + end + end + built = forgeBuild(TUTORIAL_OUT .. TUTORIAL.name .. ".singe") + forgeClose() + + return built +end diff --git a/util/forgeKit.py b/util/forgeKit.py new file mode 100644 index 000000000..7c5bae84f --- /dev/null +++ b/util/forgeKit.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# Makes the Forge tutorial kit -- the pictures, sounds, music, clip, and model the tutorials in +# docs/Forge.adoc tell the reader to use -- into assets/Forge/kit/, which the build packs into +# Forge.game. Everything here is drawn or synthesised by this script, so the kit can be remade +# whenever a tutorial wants something changed: +# +# python3 util/forgeKit.py +# +# Needs Pillow and ffmpeg. The Fox model is copied from testScripts/Models (CC-BY 4.0, see the +# LICENSE the script writes). Names are ASCII and camelCase, as the tutorials print them. + +import math +import os +import shutil +import struct +import subprocess +import sys +import wave + +from PIL import Image, ImageDraw + +KIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "Forge", "kit") +MODELS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "Models") +RATE = 22050 + +OUTLINE = (24, 20, 36, 255) +SKIN = (250, 214, 170, 255) +SHIRT = (230, 90, 170, 255) +TROUSERS = (60, 70, 140, 255) +BOOT = (70, 40, 30, 255) + + +def image(w, h): + return Image.new("RGBA", (w, h), (0, 0, 0, 0)) + + +def save(img, name): + img.save(os.path.join(KIT, name), optimize=True) + print(" " + name) + + +def outlined(draw, box, fill, shape="rect"): + if shape == "ellipse": + draw.ellipse(box, fill=fill, outline=OUTLINE) + else: + draw.rectangle(box, fill=fill, outline=OUTLINE) + + +# ---- the hero: 8 frames of 32 x 48, facing right: idle, walk x4, jump, fall, hit ---- +def heroFrame(draw, ox, pose): + # Legs first, so the body sits over them. + strides = {"idle": (0, 0), "w1": (-4, 4), "w2": (-2, 2), "w3": (4, -4), "w4": (2, -2), "jump": (-3, 3), "fall": (2, 2), "hit": (-2, -2)} + back, front = strides[pose] + lift = -4 if pose == "jump" else 0 + outlined(draw, (ox + 11 + back, 28 + lift, ox + 16 + back, 44 + lift), TROUSERS) + outlined(draw, (ox + 10 + back, 42 + lift, ox + 17 + back, 46 + lift), BOOT) + outlined(draw, (ox + 16 + front, 28 + lift, ox + 21 + front, 44 + lift), TROUSERS) + outlined(draw, (ox + 15 + front, 42 + lift, ox + 22 + front, 46 + lift), BOOT) + # Body and arms. + outlined(draw, (ox + 9, 17 + lift, ox + 22, 30 + lift), SHIRT) + if pose == "jump": + outlined(draw, (ox + 6, 10 + lift, ox + 10, 24 + lift), SHIRT) + outlined(draw, (ox + 21, 10 + lift, ox + 25, 24 + lift), SHIRT) + elif pose == "hit": + outlined(draw, (ox + 4, 18 + lift, ox + 10, 22 + lift), SHIRT) + outlined(draw, (ox + 21, 18 + lift, ox + 27, 22 + lift), SHIRT) + else: + swing = {"w1": 3, "w2": 1, "w3": -3, "w4": -1}.get(pose, 0) + outlined(draw, (ox + 6, 19 + lift, ox + 10, 29 + lift + swing), SHIRT) + outlined(draw, (ox + 21, 19 + lift, ox + 25, 29 + lift - swing), SHIRT) + # Head, with the eye on the right so the art faces right. + outlined(draw, (ox + 8, 3 + lift, ox + 23, 18 + lift), SKIN, "ellipse") + draw.rectangle((ox + 8, 3 + lift, ox + 23, 8 + lift), fill=(90, 50, 30, 255)) + draw.rectangle((ox + 18, 9 + lift, ox + 20, 11 + lift), fill=OUTLINE) + if pose == "hit": + draw.line((ox + 17, 9 + lift, ox + 21, 12 + lift), fill=OUTLINE) + draw.line((ox + 21, 9 + lift, ox + 17, 12 + lift), fill=OUTLINE) + + +def hero(): + img = image(256, 48) + draw = ImageDraw.Draw(img) + for at, pose in enumerate(["idle", "w1", "w2", "w3", "w4", "jump", "fall", "hit"]): + heroFrame(draw, at * 32, pose) + save(img, "hero.png") + + +def coin(): + img = image(16, 16) + draw = ImageDraw.Draw(img) + outlined(draw, (1, 1, 14, 14), (250, 200, 40, 255), "ellipse") + draw.ellipse((4, 4, 11, 11), outline=(200, 140, 20, 255)) + draw.point((5, 5), fill=(255, 250, 200, 255)) + save(img, "coin.png") + + +def key(): + img = image(16, 16) + draw = ImageDraw.Draw(img) + draw.ellipse((1, 4, 8, 11), fill=(250, 200, 40, 255), outline=OUTLINE) + draw.rectangle((8, 7, 14, 8), fill=(250, 200, 40, 255), outline=OUTLINE) + draw.rectangle((12, 8, 12, 11), fill=(250, 200, 40, 255), outline=OUTLINE) + draw.rectangle((14, 8, 14, 10), fill=(250, 200, 40, 255), outline=OUTLINE) + save(img, "key.png") + + +def crate(): + img = image(32, 32) + draw = ImageDraw.Draw(img) + outlined(draw, (0, 0, 31, 31), (170, 120, 60, 255)) + draw.rectangle((3, 3, 28, 28), outline=(120, 80, 40, 255)) + draw.line((3, 3, 28, 28), fill=(120, 80, 40, 255), width=2) + draw.line((28, 3, 3, 28), fill=(120, 80, 40, 255), width=2) + save(img, "crate.png") + + +def door(): + img = image(32, 48) + draw = ImageDraw.Draw(img) + outlined(draw, (2, 0, 29, 47), (120, 70, 40, 255)) + draw.rectangle((6, 4, 25, 43), outline=(80, 45, 25, 255)) + draw.line((15, 4, 15, 43), fill=(80, 45, 25, 255)) + draw.ellipse((19, 22, 23, 26), fill=(250, 200, 40, 255), outline=OUTLINE) + save(img, "door.png") + + +def spike(): + img = image(32, 16) + draw = ImageDraw.Draw(img) + for at in range(4): + x = at * 8 + draw.polygon([(x, 15), (x + 4, 0), (x + 8, 15)], fill=(200, 200, 215, 255), outline=OUTLINE) + save(img, "spike.png") + + +def tiles(): + img = image(128, 32) + draw = ImageDraw.Draw(img) + # 1 grass, 2 dirt, 3 stone, 4 brick. + outlined(draw, (0, 0, 31, 31), (140, 90, 50, 255)) + draw.rectangle((1, 1, 30, 8), fill=(90, 180, 70, 255)) + for x in range(2, 30, 4): + draw.line((x, 1, x, 4), fill=(60, 140, 50, 255)) + outlined(draw, (32, 0, 63, 31), (140, 90, 50, 255)) + for x, y in [(38, 8), (50, 20), (44, 26), (56, 6)]: + draw.point((x, y), fill=(110, 70, 40, 255)) + outlined(draw, (64, 0, 95, 31), (130, 130, 145, 255)) + draw.rectangle((68, 4, 80, 14), outline=(100, 100, 115, 255)) + draw.rectangle((78, 16, 92, 28), outline=(100, 100, 115, 255)) + outlined(draw, (96, 0, 127, 31), (170, 70, 60, 255)) + for row in range(4): + y = row * 8 + draw.line((96, y + 7, 127, y + 7), fill=(120, 40, 35, 255)) + offset = 8 if row % 2 else 0 + for x in range(96 + offset, 128, 16): + draw.line((x, y, x, y + 7), fill=(120, 40, 35, 255)) + save(img, "tiles.png") + + +def enemy(): + img = image(128, 32) + draw = ImageDraw.Draw(img) + for at in range(4): + ox = at * 32 + flap = [0, 4, 8, 4][at] + draw.polygon([(ox + 2, 16 + flap), (ox + 12, 12), (ox + 12, 20)], fill=(120, 60, 160, 255), outline=OUTLINE) + draw.polygon([(ox + 30, 16 + flap), (ox + 20, 12), (ox + 20, 20)], fill=(120, 60, 160, 255), outline=OUTLINE) + outlined(draw, (ox + 9, 8, ox + 22, 24), (150, 80, 190, 255), "ellipse") + draw.rectangle((ox + 12, 13, ox + 13, 15), fill=(255, 240, 80, 255)) + draw.rectangle((ox + 18, 13, ox + 19, 15), fill=(255, 240, 80, 255)) + save(img, "enemy.png") + + +def ship(): + img = image(32, 32) + draw = ImageDraw.Draw(img) + draw.polygon([(16, 1), (28, 26), (16, 20), (4, 26)], fill=(200, 210, 230, 255), outline=OUTLINE) + draw.polygon([(16, 8), (20, 18), (12, 18)], fill=(80, 160, 240, 255), outline=OUTLINE) + draw.rectangle((13, 26, 18, 30), fill=(250, 150, 40, 255), outline=OUTLINE) + save(img, "ship.png") + + +def shot(): + img = image(8, 8) + draw = ImageDraw.Draw(img) + draw.ellipse((1, 0, 6, 7), fill=(255, 240, 120, 255), outline=(200, 120, 20, 255)) + save(img, "shot.png") + + +def rock(): + img = image(32, 32) + draw = ImageDraw.Draw(img) + draw.polygon([(6, 4), (22, 2), (30, 12), (28, 26), (14, 30), (3, 20)], fill=(140, 130, 120, 255), outline=OUTLINE) + draw.polygon([(10, 10), (18, 8), (20, 16), (12, 18)], fill=(110, 100, 95, 255)) + save(img, "rock.png") + + +def target(): + img = image(48, 48) + draw = ImageDraw.Draw(img) + for radius, colour in [(23, (230, 60, 60, 255)), (17, (245, 245, 245, 255)), (11, (230, 60, 60, 255)), (5, (245, 245, 245, 255))]: + draw.ellipse((24 - radius, 24 - radius, 24 + radius, 24 + radius), fill=colour, outline=OUTLINE) + save(img, "target.png") + + +# ---- painted rooms for the adventure, 720 x 480 ---- +def yard(): + img = Image.new("RGB", (720, 480), (120, 170, 230)) + draw = ImageDraw.Draw(img) + for y in range(0, 200): + draw.line((0, y, 720, y), fill=(100 + y // 4, 150 + y // 5, 230)) + # A wall along the back, with a doorway on the right. + draw.rectangle((0, 200, 720, 300), fill=(180, 150, 110)) + for row in range(5): + y = 200 + row * 20 + draw.line((0, y + 19, 720, y + 19), fill=(140, 110, 80)) + offset = 30 if row % 2 else 0 + for x in range(offset, 720, 60): + draw.line((x, y, x, y + 19), fill=(140, 110, 80)) + draw.rectangle((560, 210, 620, 300), fill=(50, 35, 25)) + # The floor, in perspective, and a pillar in front of it. + draw.polygon([(0, 300), (720, 300), (720, 480), (0, 480)], fill=(160, 140, 100)) + for at in range(0, 720, 40): + draw.line((at, 300, at * 1.6 - 220, 480), fill=(140, 120, 85)) + for y in range(300, 480, 30): + draw.line((0, y, 720, y), fill=(140, 120, 85)) + draw.rectangle((300, 120, 340, 420), fill=(200, 200, 210), outline=(90, 90, 100)) + draw.rectangle((290, 110, 350, 130), fill=(180, 180, 190), outline=(90, 90, 100)) + draw.rectangle((290, 410, 350, 430), fill=(180, 180, 190), outline=(90, 90, 100)) + img.save(os.path.join(KIT, "yard.png"), optimize=True) + print(" yard.png") + + +def hall(): + img = Image.new("RGB", (720, 480), (60, 50, 70)) + draw = ImageDraw.Draw(img) + draw.rectangle((0, 0, 720, 280), fill=(90, 70, 90)) + for x in range(0, 720, 90): + draw.rectangle((x + 20, 60, x + 60, 240), fill=(120, 100, 120), outline=(60, 45, 60)) + draw.polygon([(0, 280), (720, 280), (720, 480), (0, 480)], fill=(120, 90, 70)) + for y in range(280, 480, 25): + draw.line((0, y, 720, y), fill=(100, 75, 60)) + draw.rectangle((80, 120, 140, 280), fill=(40, 30, 35)) + draw.rectangle((520, 150, 640, 250), fill=(200, 180, 120), outline=(120, 100, 60)) + draw.text((535, 190), "the hall", fill=(60, 45, 30)) + img.save(os.path.join(KIT, "hall.png"), optimize=True) + print(" hall.png") + + +# ---- sounds ---- +def tone(seconds, pitch, kind="square", pitchEnd=None, fade=True): + samples = [] + count = int(RATE * seconds) + for at in range(count): + t = at / RATE + f = pitch if pitchEnd is None else pitch + (pitchEnd - pitch) * at / count + phase = (t * f) % 1.0 + if kind == "square": + v = 1.0 if phase < 0.5 else -1.0 + elif kind == "saw": + v = phase * 2 - 1 + elif kind == "noise": + v = (hash((at * 7919) % 104729) % 2000) / 1000.0 - 1.0 + else: + v = math.sin(phase * 2 * math.pi) + gain = (1.0 - at / count) if fade else 1.0 + samples.append(v * gain * 0.4) + return samples + + +def writeWav(name, samples): + with wave.open(os.path.join(KIT, name), "wb") as out: + out.setnchannels(1) + out.setsampwidth(2) + out.setframerate(RATE) + out.writeframes(b"".join(struct.pack(" 0: + cy = 260 - int(120 * rise) + for radius, colour in [(30, (230, 60, 60)), (20, (245, 245, 245)), (10, (230, 60, 60))]: + draw.ellipse((300 - radius, cy - radius, 300 + radius, cy + radius), fill=colour) + # A hand reaching in from the right between 9 and 11 seconds. + reach = max(0.0, min(1.0, (t - 9) / 1)) - max(0.0, min(1.0, (t - 11) / 1)) + if reach > 0: + x = 480 - int(160 * reach) + draw.rectangle((x, 120, 480, 160), fill=(250, 214, 170)) + draw.ellipse((x - 30, 105, x + 30, 175), fill=(250, 214, 170)) + # The clock, so a reader can see the second a moment happens. + draw.rectangle((20, 300, 460, 310), outline=(200, 200, 210)) + draw.rectangle((20, 300, 20 + int(440 * t / 12), 310), fill=(255, 207, 74)) + draw.text((22, 286), "%4.1f s" % t, fill=(230, 230, 240)) + img.resize((720, 480), Image.NEAREST).save(os.path.join(work, "f%04d.png" % frame)) + subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps), "-i", os.path.join(work, "f%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "28", "-g", "24", os.path.join(KIT, "clip.mkv")], check=True) + shutil.rmtree(work) + print(" clip.mkv") + + +def model(): + shutil.copy(os.path.join(MODELS, "Fox.glb"), os.path.join(KIT, "fox.glb")) + print(" fox.glb") + + +def notes(): + with open(os.path.join(KIT, "LICENSE"), "w") as out: + out.write("The Forge tutorial kit.\n\n" + "Everything here except fox.glb was drawn or synthesised by util/forgeKit.py in the\n" + "Singe source tree and is released under CC0: use it in your own games as you like.\n\n" + "fox.glb is the Fox from the Khronos glTF sample models, CC-BY 4.0: model by PixelMannen,\n" + "rigging and animation by tomkranis, glTF conversion by AsoboStudio and scurest.\n" + "A game that ships it should carry this credit.\n") + with open(os.path.join(KIT, "README.txt"), "w") as out: + out.write("The Forge tutorial kit, addressed from a game as Forge/kit/.\n\n" + "hero.png a sprite sheet, 8 columns of 32 x 48, facing right: idle, walk x4, jump, fall, hit\n" + "enemy.png a sheet, 4 columns of 32 x 32, flapping\n" + "tiles.png a tile sheet, 4 columns of 32 x 32: grass, dirt, stone, brick\n" + "coin.png key.png crate.png door.png spike.png ship.png shot.png rock.png target.png stills\n" + "yard.png hall.png painted rooms, 720 x 480, for the adventures\n" + "jump.wav coin.wav shot.wav hit.wav click.wav door.wav clips\n" + "loop.ogg eight seconds of music that loops\n" + "clip.mkv twelve seconds of 720 x 480 video at 24 frames a second: a door opens at 3 s, a target rises at 6 s, a hand reaches at 9 s\n" + "fox.glb an animated model (see LICENSE)\n") + print(" LICENSE, README.txt") + + +if __name__ == "__main__": + os.makedirs(KIT, exist_ok=True) + print("Forge kit into " + KIT) + for make in [hero, coin, key, crate, door, spike, tiles, enemy, ship, shot, rock, target, yard, hall, sounds, clip, model, notes]: + make() diff --git a/util/forgeShots.py b/util/forgeShots.py new file mode 100644 index 000000000..7afa7c714 --- /dev/null +++ b/util/forgeShots.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Puts the tutorial pictures into the book: every NN-name.png a tutorial scene took (in +# screenshots/tutN/, where the harness leaves them) is copied to docs/images/tutorials/, scaled to +# the width a page needs and reduced to a palette, so ten tutorials of full-window screenshots do +# not make Forge.pdf ten times its size. +# +# python3 util/forgeShots.py [screenshots/tut1 ...] +# +# With no arguments every screenshots/tut* folder is taken. + +import glob +import os +import sys + +from PIL import Image + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT = os.path.join(ROOT, "docs", "images", "tutorials") +WIDTH = 768 + + +def place(path): + img = Image.open(path).convert("RGB") + if img.width > WIDTH: + img = img.resize((WIDTH, img.height * WIDTH // img.width), Image.LANCZOS) + img = img.quantize(colors=128, method=Image.Quantize.MEDIANCUT) + img.save(os.path.join(OUT, os.path.basename(path)), optimize=True) + + +if __name__ == "__main__": + folders = sys.argv[1:] or sorted(glob.glob(os.path.join(ROOT, "screenshots", "tut*"))) + os.makedirs(OUT, exist_ok=True) + count = 0 + for folder in folders: + for path in sorted(glob.glob(os.path.join(folder, "[0-9][0-9]-*.png"))): + place(path) + count += 1 + print("%d pictures into %s" % (count, OUT)) diff --git a/util/forgeVocabulary.lua b/util/forgeVocabulary.lua index 05b9ea49c..7dff2e8a4 100644 --- a/util/forgeVocabulary.lua +++ b/util/forgeVocabulary.lua @@ -19,11 +19,14 @@ local function keysOf(t) end -local function paramsText(params) +-- A parameter and its type, or the words it takes when the manifest lists them. +local function paramsText(params, choices) local parts = {} for _, key in ipairs(keysOf(params)) do - parts[#parts + 1] = "`" .. key .. "` (" .. params[key] .. ")" + local words = (choices or {})[key] + + parts[#parts + 1] = "`" .. key .. "` (" .. (words and table.concat(words, ", ") or params[key]) .. ")" end if #parts == 0 then return "--" @@ -45,7 +48,7 @@ local function section(title, set, extra) print("| `" .. name .. "`") print("| " .. (entry.help or "")) - print("| " .. paramsText(entry.params or entry.filter)) + print("| " .. paramsText(entry.params or entry.filter, entry.choices)) if extra then print("| " .. (entry.waits and "yes" or "")) end