diff --git a/CHANGELOG b/CHANGELOG index 8e5d20a93..93f03dd33 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,22 @@ SINGE 3.00 API Changes ----------- +- 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. + +- soundPlay takes a volume as its third argument, and + soundSetChannelVolume changes a playing channel's own volume, both under + the master from soundSetVolume: a quiet footstep and a loud shot can + share one master. The master used to be applied through the mixer's + tag gain, which overwrote the distance fade of positioned channels + whenever it changed; it is applied per track now. + +- materialSetOccluder makes a material write depth and no colour: an + invisible shape that hides what walks behind it while what was drawn + behind it -- a painted room, the disc -- shows through, which is how a + 3D character walks behind a pillar that is only in the picture. + - --gamedir names the directory holding the games, the .game files and the Singe support folder, so the executable can live anywhere. It becomes the working directory; relative names count from it. The @@ -1510,6 +1526,13 @@ Fixes read as pixels. The menu's backdrop stops rewriting its grid colours every frame once the grid is lit. +- navAgentNew placed an agent from the node's world position as of the + last drawn frame, so an agent made before the first frame -- or in a + game that never draws the scene, which is what a 2D room walking a + navigation mesh is -- started at the origin, off the mesh, and never + moved. The transforms are refreshed first now, as navAddNode already + did. + Net.singe verified a connection against Linux's CA list by path, so without a pin every HTTPS request failed on Windows, macOS and the handhelds. The engine now extracts Mozilla's CA list as @@ -1521,10 +1544,79 @@ Fixes or scriptExecute is reported like any other bad API argument -- the script line and the call named -- rather than as a bare exit message. +- Forge's description format is new (FORGE.md section 4): kinds with + instances, rooms that remember themselves, rules triggered by events + (collisions between kinds, hits, deaths, timers, disc frames) as well as + every frame, vars and states on every instance, expressions where a + number goes, any-of groups, timers, sound, a HUD document bound to vars, + actions that take time run as coroutines, and hitbox tracks keyed to + disc frames or time. A shoot-em-up and a light gun game over video + prove it (testScripts/author, scenes 58 and 59). The editor gained + kinds, rooms, event pickers, and a timeline that scrubs the disc's video + under the cursor (scene 60). Nothing of the first slice's format loads; + it was never released. + +- Forge builds 3D games (FORGE.md section 5): a scene3d layer, model, + mesh, light, billboard, and text looks, the character controller moving + relative to a camera that is fixed, follows, orbits, sits in first + person, or rides a rail of points with stops, enemies that seek, an + animator playing a clip per state, targets with zones on named bones so + a gun's ray says which part it hit, ragdolls on death, bodies and + triggers. A rail shooter in the manner of House of the Dead and a 3D + platformer prove it (scenes 61 and 62), and the editor shows a 3D room + as the scene itself under an orbiting camera (scene 63). + +- Forge builds adventure games (FORGE.md section 6): rooms that remember + themselves, walk areas baked to a navigation mesh in a painted room or + a modelled one, hotspots, a verb line and a text parser with the most + specific rule winning, an inventory, dialogue trees, cut-scenes across + rooms with fades, depth sorting and scaling, and whole-game saves. The + same rules run a two-room adventure in 2D and in 3D (scenes 64 and 65). + +- Forge builds racing games and tower defences (FORGE.md section 7): + vehicles on the engine's vehicle physics with an opponent that drives a + track on its own, walkers that follow a target or patrol a lane, turrets + that fire at what is in range, instances placed by a click on the floor, + thrust, and hinge, ball, and slider joints (scenes 66 and 67). + +- Forge builds branching video games and carries the arcade plumbing + (FORGE.md section 8): windows on the disc that want a move, with the + disc sent to a success or a failure clip, the score bezel mirroring the + game's vars, coins, continues, a results card with the best score kept, + and scores sent to the master service's board (scene 68). + +- Forge's editor gained a search in every picker, a file picker for any + field that names a file, polygon drawing for walk areas and hotspot + outlines, a dialogue outline panel, and play-from-this-room (FORGE.md + section 9, scene 69). + +- Forge's long tail (FORGE.md section 10): sprite sheets stepped by + state, tile maps from a sheet, MIDI notes as events, water that bodies + float in, boats under their propellers, and soft bodies (scenes 70 and + 71). Every kind of game in FORGE.md's catalogue now has its vocabulary. + +- Forge's sprites turn (a var, a spin behaviour, or a projectile aiming + along its flight), a particles look streams from an instance, the + checker names misspelt fields, and 2D particle bursts are drawn at all + (they never had been). + +- Forge builds painted rooms in 3D with occluders, and its 3D viewport + gained axis gizmos with a turn handle; entities carry a rotation, and + a sprite faces left through a frame range of its own (FORGE.md section + 12, scenes 70, 79, and 80). + +- Forge's catalogue is written out (FORGE.md section 11): a first-person + shooter, breakout, a maze, third-person action, a space shooter, a + rhythm game, and bowling join the samples (scenes 72 to 78), and what + they found is fixed: a gun's ray passes through what is not a target, + events raised while the first room is built reach the rules, and a + rule on a key or a room can run once per instance of a kind. + - Forge has its own manual, docs/Forge.adoc, built to Forge.html and Forge.pdf beside the Singe manual; the engine's manual keeps a pointer. - The build packs Forge.game itself, with the manual inside, and Forge's - first run copies Forge.pdf out to its data directory and says where. + The build packs Forge.game itself, with the manual and four samples + that need no art inside, and Forge's first run copies Forge.pdf out to + its data directory and says where. R redoes what U undid, forty steps deep. diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a6e8285e..afb28ddf1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -298,11 +298,22 @@ singeEmbedLua(thirdparty/bump.lua/bump.lua "") # Forge's manual, rendered the same way. It is not embedded: the superbuild packs it into # Forge.game (cmake/Superbuild.cmake), which is the one thing that ships Forge. +# Its vocabulary tables are generated from the manifest in Author.singe, so the manual cannot +# drift from it; the generated file is kept in the tree for a host without a Lua interpreter. +find_program(LUA_INTERPRETER NAMES lua5.4 lua) +if(LUA_INTERPRETER) + add_custom_command( + OUTPUT ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc + COMMAND ${CMAKE_COMMAND} -E chdir ${CMAKE_SOURCE_DIR} ${LUA_INTERPRETER} util/forgeVocabulary.lua > ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc + DEPENDS assets/Forge/Author.singe util/forgeVocabulary.lua + COMMENT "Writing the Forge vocabulary" + ) +endif() 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 + DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc COMMENT "Rendering Forge.pdf" VERBATIM ) diff --git a/assets/Forge/Author.singe b/assets/Forge/Author.singe index 664c4c84d..b670b5c31 100644 --- a/assets/Forge/Author.singe +++ b/assets/Forge/Author.singe @@ -23,43 +23,119 @@ --- The runtime an authored game is compiled against (PLAN section 58). +-- The runtime an authored game is compiled against (FORGE.md). -- --- A game made with the authoring tools is not interpreted: Forge/AuthorCompile.singe turns its --- description into ordinary Lua, and this file is the library that Lua calls. Rules become real --- `if` statements, so nothing walks a table every frame and the result can be opened in ZeroBrane --- and edited by hand like any other game. +-- A game made with Forge is not interpreted: Forge/AuthorCompile.singe turns its description into +-- ordinary Lua, and this file is the library that Lua calls. Rules become real functions, so +-- nothing walks a table every frame, and the result can be opened in ZeroBrane and edited by hand +-- like any other game. -- --- Three nouns, and no notion of genre anywhere: +-- The nouns (FORGE.md section 3): -- --- layers which of the engine's own layers the game uses -- disc, overlay, scene, GUI. --- A genre is only a choice of layers; see AUTHOR.layers below. --- entities a thing on a layer: a node, a look, and state. --- behaviours a bundle attached to an entity, over engine calls that already exist. +-- 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. +-- 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. -- --- AUTHOR is also the manifest the compiler and (later) the editor read: every condition, action --- and behaviour declares its parameters and the Lua it emits, so a new genre is a pack of entries --- here rather than a new release of anything. +-- AUTHOR is also the manifest the compiler and the editor read: every layer, look, behaviour, +-- condition, action, and event declares its parameters and the Lua it emits, so a new kind of +-- game is a set of entries here rather than a new release of anything. +-- +-- The manifest's emit functions receive their parameters already as Lua source fragments -- the +-- compiler has turned a number into a number, an expression into Lua, an entity name into a lookup +-- -- so an entry only concatenates. The parameter types (FORGE.md section 3): +-- +-- number a number, or an expression that makes one +-- expression any expression +-- string text, quoted +-- boolean true or false +-- entity "self", "other", or an entity id +-- kind a kind's name +-- scancode a key, from SCANCODE +-- switch a switch, from SWITCH_* +-- file a file name +-- state a state name +-- track a track's name +-- room a room's name +-- lua Lua, as written AUTHOR = { layers = {}, looks = {}, behaviours = {}, conditions = {}, - actions = {} + actions = {}, + events = {} } -AUTHOR_WORLD = {} -- Entities by id. -AUTHOR_ORDER = {} -- and in the order they were declared, which is draw order. -AUTHOR_LAYERS = {} -AUTHOR_STARTED = 0 -- singeGetTicks() when the game began. +AUTHOR_GAME = nil -- The description handed to authorBegin. +AUTHOR_VARS = {} -- Game scope vars: score, lives, flags, whatever the game declared. +AUTHOR_LIVE = {} -- Instances in the current room, in draw order. +AUTHOR_BY_ID = {} -- Live instances by id. +AUTHOR_BY_NODE = {} -- Live instances by node, for what physics and rays report. +AUTHOR_3D = false -- Whether the game has a scene3d layer. +AUTHOR_MOUSE = { dx = 0, dy = 0 } -- Relative motion this frame, for a camera that looks. +AUTHOR_ROOM = nil -- The current room's table. +AUTHOR_STARTED = 0 -- singeGetTicks() when the game began. +AUTHOR_CONTROLS = true -- False while a cut-scene has the controls. +AUTHOR_FONT = nil +AUTHOR_FONT_POINTS = 18 -local GRAVITY_DEFAULT = 1400 -- Overlay units a second squared; a 480 tall screen wants about this. -local PLAYER_RADIUS = 0.5 -- Fractions of an entity's box, for the capsule a platformer stands in. +local GRAVITY_DEFAULT = 1400 -- Overlay units a second squared; a 480 tall screen wants about this. +local PLAYER_RADIUS = 0.5 -- Fractions of an entity's box, for the capsule a platformer stands in. +local MOUSE_LEFT = nil -- Filled from SWITCH_BUTTON3 once Framework is in: the gun's trigger. +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 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. +local FAR_AWAY = 300 -- A 3D projectile past this from the origin is dropped. +local LOOK_SPEED = 0.2 -- Degrees of turn per pixel of mouse motion. +local PITCH_LIMIT = 80 -- Degrees a looking camera may tilt. +local models = {} -- Loaded models by file. +local meshes = {} -- Built primitives by description. +local cameraNode = nil -- The node the scene is drawn from, when a camera behaviour has one. +local NAV_SCALE = 0.01 -- Overlay pixels to navigation units in a 2D room: a 720 wide room is 7.2 across. +local NAV_RADIUS_2D = 0.08 -- Agents in a 2D room, in navigation units: eight pixels. +local NAV_HEIGHT_2D = 0.5 +local WALK_TIMEOUT = 12 -- Seconds a walkTo waits before giving up on an unreachable point. +local FADE_DEFAULT = 0.6 -- Seconds a fade takes when the rule does not say. +local CHOICE_KEYS = { "MAIN_1", "MAIN_2", "MAIN_3", "MAIN_4", "MAIN_5", "MAIN_6", "MAIN_7", "MAIN_8", "MAIN_9" } +local dialogues = {} -- Compiled dialogues by name, from authorDialogues. +local talking = nil -- The dialogue on screen: { lines, choices, chosen }. +local fadeLevel = 0 -- 0 clear, 1 black. +local parserLayer = nil -- The parser layer, when the game has one. +AUTHOR_TYPING = "" -- What has been typed at the parser's prompt. +local bezel = nil -- The score bezel layer, when the game has one. +local results = nil -- The results card after gameOver: { score, best }. +local masterReady = false -- Whether the master service's script has been loaded. +local BEST_KEY = "forge.best" -AUTHOR_FONT_POINTS = 18 -- The default the text look draws at. +local rules = {} -- Compiled rules by event name. +local rulesReady = false -- authorRules has been called; before it, events are kept for it. +local pending = {} -- Events raised before the rules arrived: the first room's start. +local pairsToTest = {} -- Kind pairs the collision rules ask about. +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 sprites = {} -- Loaded images by file. +local pointers = {} -- Injected pointer positions by player, for tests. +local pressedNow = {} -- Keys and switches that went down this frame. +local lastTime = 0 +local lastFrame = -1 +local nextSerial = 1 +local hud = nil -- { gui, document, bind } +local sayLine = nil -- { text, until } +local flashLine = nil -- { r, g, b, until, seconds } +local gameOver = false +local music = nil +-- ===== Small helpers ========================================================================== + -- Seconds since the game started. Every rule that talks about time uses this, so a game is -- reproducible under --deterministic rather than depending on how fast the machine draws. function authorTime() @@ -68,7 +144,231 @@ end function authorEntity(id) - return AUTHOR_WORLD[id] + return AUTHOR_BY_ID[id] +end + + +-- Every live instance of a kind, in draw order. +function authorEach(kind) + local list = {} + + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.alive and (instance.kind == kind) then + list[#list + 1] = instance + end + end + + return list +end + + +function authorCount(kind) + return #authorEach(kind) +end + + +-- Where an instance is, in overlay coordinates. Everything is a node, whether or not the scene is +-- drawing: nodes exist without a GPU, which is what lets a 2D game run on a machine with none. +function authorPosition(instance) + local x, y, z = nodeGetPosition(instance.node) + + return x, y, z +end + + +function authorX(instance) + local x = nodeGetPosition(instance.node) + + return x +end + + +function authorY(instance) + local _, y = nodeGetPosition(instance.node) + + return y +end + + +-- An instance's field, for expressions: x, y, z, id, kind, state, or one of its vars. +function authorField(instance, name) + if instance == nil then + return nil + end + if name == "x" then + return authorX(instance) + elseif name == "y" then + return authorY(instance) + elseif name == "z" then + local _, _, z = nodeGetPosition(instance.node) + + return z + elseif (name == "id") or (name == "kind") then + return instance[name] + end + + return instance.vars[name] +end + + +-- The distance between two instances -- either may be given by id -- across the picture in 2D +-- and through the scene in 3D. +function authorDistance(a, b) + if type(a) == "string" then + a = AUTHOR_BY_ID[a] + end + if type(b) == "string" then + b = AUTHOR_BY_ID[b] + end + if (a == nil) or (b == nil) then + return math.huge + end + local ax, ay, az = authorPosition(a) + local bx, by, bz = authorPosition(b) + + if AUTHOR_3D then + return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2 + (az - bz) ^ 2) + end + + return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2) +end + + +function authorRandom(low, high) + if high == nil then + return math.random() * (low or 1) + end + + return low + math.random() * (high - low) +end + + +function authorHas(item) + for _, held in ipairs(AUTHOR_VARS.inventory or {}) do + if held == item then + return true + end + end + + return false +end + + +-- How big an instance is, from its look and its own scale. Every look answers. +function authorSize(instance) + local look = AUTHOR.looks[instance.look.kind] + local scale = instance.scale or 1 + local w, h, d + + if look and look.size then + w, h, d = look.size(instance) + else + w, h, d = instance.look.w or 0, instance.look.h or 0, instance.look.d or 0 + end + + return w * scale, h * scale, (d or 0) * scale +end + + +-- The look's own size, before the entity's scale: what a body on the node wants, since bodies +-- take the node's scale themselves. +function authorUnscaledSize(instance) + local w, h, d = authorSize(instance) + local scale = instance.scale or 1 + + return w / scale, h / scale, d / scale +end + + +-- An instance's box in overlay coordinates, centred on it, or standing on it when its look says +-- anchor feet, and scaled with depth in a room that scales. +function authorBounds(instance) + local x, y = authorPosition(instance) + local w, h = authorSize(instance) + local scale = authorDepthScale(y) + + w = w * scale + h = h * scale + if instance.look.anchor == "feet" then + return x - w / 2, y - h, w, h + end + + return x - w / 2, y - h / 2, w, h +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. +function authorDepthScale(y) + local table_ = AUTHOR_ROOM and AUTHOR_ROOM.scaleBy or nil + + if (table_ == nil) or (#table_ < 2) then + return 1 + end + local before, after + + for _, key in ipairs(table_) do + if (key.y <= y) and ((before == nil) or (key.y > before.y)) then + before = key + end + if (key.y >= y) and ((after == nil) or (key.y < after.y)) then + after = key + end + end + before = before or after + after = after or before + if (before == after) or (after.y == before.y) then + return before.scale + end + + return before.scale + (after.scale - before.scale) * (y - before.y) / (after.y - before.y) +end + + +-- A key named in a behaviour's parameters, as the engine delivers it. Rules have theirs compiled; +-- behaviours get the description's own text and resolve it here. +local function keyValue(name) + if type(name) == "string" then + return SCANCODE[name] and SCANCODE[name].value or nil + end + + return name +end + + +-- A switch named the same way. +local function switchValue(name) + if type(name) == "string" then + return _G[name] + end + + return name +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. +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) + else + sprites[key] = spriteLoad(file) + end + end + + return sprites[key] +end + + +local function soundFor(file) + if sounds[file] == nil then + sounds[file] = soundLoad(file) + end + + return sounds[file] end @@ -77,9 +377,9 @@ end -- Each declares what it needs of the engine. Nothing here knows what kind of game is being made. AUTHOR.layers.world2d = { - help = "A flat world with gravity: physics in the XY plane, drawn into the overlay.", + help = "A flat world with gravity: physics in the XY plane, drawn into the overlay.", params = { gravity = "number" }, - begin = function(layer) + begin = function(layer) physicsSetEnabled(true) physicsSet2D(true) physicsSetGravity(0, layer.gravity or GRAVITY_DEFAULT, 0) @@ -87,14 +387,14 @@ AUTHOR.layers.world2d = { } AUTHOR.layers.overlay = { - help = "Flat drawing over everything: scores, prompts, a HUD.", + help = "Flat drawing over everything: sprites, scores, prompts.", params = {}, begin = function() end } AUTHOR.layers.disc = { - help = "The video the game is played over.", - params = { start = "number" }, + help = "The video the game is played over. The file is what the editor scrubs; games.dat names it when the game runs.", + params = { file = "file", start = "number" }, begin = function(layer) discPlay() if layer.start then @@ -103,52 +403,444 @@ AUTHOR.layers.disc = { end } +AUTHOR.layers.hud = { + help = "An RmlUi document over everything. bind maps element ids to game vars, refreshed as they change.", + params = { document = "file", bind = "table" }, + begin = function(layer) + local gui = guiNew(overlayGetWidth(), overlayGetHeight()) + + hud = { gui = gui, document = guiLoad(gui, layer.document), bind = layer.bind or {}, shown = {} } + end +} + +AUTHOR.layers.music = { + help = "A track that plays from the start, looping.", + params = { file = "file", volume = "number" }, + begin = function(layer) + music = musicLoad(layer.file) + if layer.volume then + musicSetVolume(layer.volume) + end + musicPlay(music, -1) + end +} + + +AUTHOR.layers.scene3d = { + help = "The 3D scene: physics in three axes, a sun, a sky, fog. Positions are world units.", + params = { gravity = "number", sky = "file", ambient = "table", fog = "table", sun = "table", fov = "number", exposure = "number" }, + begin = function(layer) + local ok, err = pcall(sceneEnable, true) + + if not ok then + debugPrint("Author: the 3D scene could not start: " .. tostring(err)) + end + AUTHOR_3D = true + physicsSetEnabled(true) + physicsSet2D(false) + physicsSetGravity(0, layer.gravity or -9.8, 0) + if layer.sky then + sceneSetSky(layer.sky) + end + if layer.ambient then + sceneSetAmbient(layer.ambient.r or 40, layer.ambient.g or 40, layer.ambient.b or 50) + end + if layer.fog then + sceneSetFog(layer.fog.r or 0, layer.fog.g or 0, layer.fog.b or 0, layer.fog.near or 30, layer.fog.far or 80) + end + if layer.exposure then + sceneSetExposure(layer.exposure) + end + if layer.sun ~= false then + local sun = layer.sun or {} + local light = lightNew(LIGHT_DIRECTIONAL) + + nodeSetPosition(light, sun.x or -5, sun.y or 10, sun.z or 6) + nodeLookAt(light, 0, 0, 0) + lightSetIntensity(light, sun.intensity or 1.5) + lightSetShadow(light, sun.shadow ~= false) + end + cameraSetPerspective(layer.fov or 60, 0.1, 500) + end +} + + +AUTHOR.layers.bezel = { + help = "The arcade score panel around the picture, showing the vars score (and score2), lives (and lives2), and credits as they change.", + params = { twin = "boolean" }, + begin = function(layer) + bezel = { twin = layer.twin, shown = {} } + scoreBezelEnable(true) + if layer.twin then + scoreBezelTwinScoreOn(true) + end + end +} + +AUTHOR.layers.parser = { + help = "A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event.", + params = { prompt = "string", words = "table", ignore = "table" }, + begin = function(layer) + parserLayer = layer + keyboardSetMode(MODE_FULL) + end +} + -- ===== Looks ================================================================================== -- --- How an entity is drawn. A look draws itself; it never knows which layer it is on. +-- How an instance is drawn. A look draws itself and says how big it is; it never knows which +-- layer it is on. The 3D looks build nodes under the instance's own and draw nothing themselves: +-- the scene draws them. AUTHOR.looks.box = { - help = "A filled rectangle, centred on the entity.", - params = { w = "number", h = "number", r = "number", g = "number", b = "number" }, - draw = function(entity) - local look = entity.look - local x, y = authorPosition(entity) - local hw = look.w / 2 - local hh = look.h / 2 - local row + 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" }, + 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 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) -- overlayBox outlines; a solid block is the outline drawn every row, which is cheap at -- these sizes and saves the game needing an image for a placeholder. - for row = math.floor(y - hh), math.floor(y + hh) do - overlayLine(x - hw, row, x + hw, row) + for row = math.floor(top), math.floor(top + h * scale) do + overlayLine(x - w * scale / 2, row, x + w * scale / 2, row) end end } AUTHOR.looks.sprite = { - help = "An image, centred on the entity.", - params = { file = "file" }, - load = function(entity) - entity.sprite = spriteLoad(entity.look.file) + 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" }, + load = function(instance) + instance.sprite = spriteFor(instance.look.file, instance.look.frames) end, - draw = function(entity) - local x, y = authorPosition(entity) + size = function(instance) + if instance.sprite == nil then + return 0, 0 + end + if (instance.look.frames or 1) > 1 then + return spriteFrameWidth(instance.sprite), spriteFrameHeight(instance.sprite) + end - spriteDraw(entity.sprite, x - spriteGetWidth(entity.sprite) / 2, y - spriteGetHeight(entity.sprite) / 2) + return spriteGetWidth(instance.sprite), spriteGetHeight(instance.sprite) + end, + draw = function(instance) + local x, y = authorPosition(instance) + local w, h = authorSize(instance) + local scale = authorDepthScale(y) * (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 + local angle = instance.vars.angle or 0 + local mirror = authorMirrored(instance) + + if (angle ~= 0) or mirror then + -- A turned or mirrored sprite gets an image of its own, so it does not turn every other + -- instance drawn from the same file, and the image is rebuilt only when something + -- about it changes. + local own = instance.ownSprite + + if own == nil then + own = (frames > 1) and spriteLoadFrames(frames, instance.look.file) or spriteLoad(instance.look.file) + instance.ownSprite = own + end + spriteFlip(own, mirror, false) + if frames > 1 then + if (instance.ownAngle ~= angle) or (instance.ownFrame ~= frame) then + spriteRotateFrame(own, angle, frame) + instance.ownAngle = angle + instance.ownFrame = frame + end + spriteDrawRotatedFrame(own, x, top + h * scale / 2, scale) + else + spriteRotate(own, angle) + spriteScale(own, scale) + spriteDraw(own, x, top + h * scale / 2, true) + end + elseif (frames > 1) or (scale ~= 1) then + spriteDrawFrame(instance.sprite, x - w * scale / 2, top, frame, scale) + else + spriteDraw(instance.sprite, x - w / 2, top) + end + end +} + +AUTHOR.looks.particles = { + help = "A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them.", + params = { r = "number", g = "number", b = "number", rate = "number", size = "number", speed = "number", life = "number", spread = "number", gravity = "number" }, + load = function(instance) + local look = instance.look + + instance.emitter = AUTHOR_3D and emitterNew(instance.node) or emitterNew() + emitterSetColor(instance.emitter, look.r or 255, look.g or 200, look.b or 80, 255, look.r or 255, look.g or 200, look.b or 80, 0) + emitterSetRate(instance.emitter, look.rate or 40) + emitterSetLife(instance.emitter, (look.life or 1) * 0.6, look.life or 1) + emitterSetSpread(instance.emitter, look.spread or 30) + if look.size then + emitterSetSize(instance.emitter, look.size, look.size * 0.2) + end + if look.speed then + emitterSetSpeed(instance.emitter, look.speed * 0.6, look.speed) + end + if look.gravity then + emitterSetGravity(instance.emitter, 0, look.gravity, 0) + end + emitterStart(instance.emitter) + end, + size = function(instance) return instance.look.size or 16, instance.look.size or 16, instance.look.size or 16 end, + draw = function(instance) + if not AUTHOR_3D then + local x, y = authorPosition(instance) + + emitterSetPosition(instance.emitter, x, y) + end end } AUTHOR.looks.text = { - help = "A line of text, its top left at the entity.", + help = "A line of text, its top left at the instance. The var text is what it says.", params = { text = "string", r = "number", g = "number", b = "number" }, - draw = function(entity) - local x, y = authorPosition(entity) + load = function(instance) + instance.vars.text = instance.vars.text or instance.look.text or "" + end, + size = function(instance) + -- Measured once per change: rendering text to measure it is not free. + if instance.measured ~= instance.vars.text then + local sprite = fontToSprite(instance.vars.text ~= "" and instance.vars.text or " ") - colorForeground(entity.look.r or 255, entity.look.g or 255, entity.look.b or 255, 255) - if entity.text ~= "" then - fontPrint(x, y, entity.text) + instance.textW = spriteGetWidth(sprite) + instance.textH = spriteGetHeight(sprite) + instance.measured = instance.vars.text + spriteUnload(sprite) + end + + return instance.textW, instance.textH + end, + draw = function(instance) + local x, y = authorPosition(instance) + + colorForeground(instance.look.r or 255, instance.look.g or 255, instance.look.b or 255, 255) + if instance.vars.text ~= "" then + fontPrint(x, y, instance.vars.text) + end + end +} + +AUTHOR.looks.none = { + help = "Nothing drawn: a trigger, a spawner, a hitbox over video, a camera.", + params = { w = "number", h = "number", d = "number" }, + size = function(instance) return instance.look.w or 0, instance.look.h or 0, instance.look.d or 0 end, + draw = function() end +} + + +local function modelFor(file) + if models[file] == nil then + models[file] = modelLoad(file) + end + + return models[file] +end + + +-- Every node under a model instance is the instance's, so a ray or a contact that reports a mesh +-- node or a bone finds its way back without asking the engine for parents (which would end the +-- game on a node a rule had already deleted). +local function claimNodes(instance, node) + AUTHOR_BY_NODE[node] = instance + instance.nodes[#instance.nodes + 1] = node + for _, child in ipairs(nodeGetChildren(node) or {}) do + claimNodes(instance, child) + end +end + + +AUTHOR.looks.model = { + help = "A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as.", + params = { file = "file", scale = "number", clip = "string", rx = "number", ry = "number", rz = "number", w = "number", h = "number", d = "number" }, + load = function(instance) + local look = instance.look + + instance.model = modelInstance(modelFor(look.file)) + claimNodes(instance, instance.model) + nodeSetParent(instance.model, instance.node) + nodeSetScale(instance.model, look.scale or 1) + nodeSetRotation(instance.model, look.rx or 0, look.ry or 0, look.rz or 0) + if look.clip then + animationPlay(instance.model, look.clip, true) + end + end, + size = function(instance) return instance.look.w or 1, instance.look.h or 1, instance.look.d or 1 end, + draw = function() end +} + + +-- A primitive mesh, shared by every instance that asks for the same one. +local function meshFor(look) + local key = table.concat({ look.shape or "box", look.w or 1, look.h or 1, look.d or 1 }, "/") + + if meshes[key] == nil then + if look.shape == "sphere" then + meshes[key] = meshSphere((look.w or 1) / 2, 24) + elseif look.shape == "cylinder" then + meshes[key] = meshCylinder((look.w or 1) / 2, look.h or 1, 24) + elseif look.shape == "plane" then + meshes[key] = meshPlane(look.w or 1, look.d or 1) + else + meshes[key] = meshBox(look.w or 1, look.h or 1, look.d or 1) + end + end + + return meshes[key] +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" }, + load = function(instance) + local look = instance.look + local material = materialNew() + + materialSetColor(material, look.r or 200, look.g or 200, look.b or 200) + -- A KTX2 texture goes straight on; any other picture is loaded as a sprite first, which is + -- what materialSetTexture takes for those. + if look.texture and look.texture ~= "" then + if look.texture:lower():match("%.ktx2$") then + materialSetTexture(material, look.texture) + else + materialSetTexture(material, spriteFor(look.texture)) + end + end + if look.unlit then + materialSetUnlit(material, true) + end + if look.metallic then + materialSetMetallic(material, look.metallic) + end + if look.roughness then + materialSetRoughness(material, look.roughness) + end + -- The editor's preview keeps an occluder visible, so it can be placed; the game hides it. + if look.occluder and not instance.preview then + materialSetOccluder(material, true) + end + instance.material = material + nodeSetMesh(instance.node, meshFor(look), material) + end, + size = function(instance) + local look = instance.look + + if look.shape == "plane" then + return look.w or 1, 0.01, look.d or 1 + end + + return look.w or 1, (look.shape == "sphere") and (look.w or 1) or (look.h or 1), (look.shape == "sphere") and (look.w or 1) or (look.d or 1) + end, + draw = function() end +} + +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" }, + 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) + local light = lightNew(kind, instance.node) + + lightSetColor(light, look.r or 255, look.g or 255, look.b or 255) + lightSetIntensity(light, look.intensity or 1) + if look.range then + lightSetRange(light, look.range) + end + if look.shadow then + lightSetShadow(light, true) + end + instance.light = light + end, + size = function() return 0.5, 0.5, 0.5 end, + draw = function() end +} + +AUTHOR.looks.billboard = { + help = "A sprite in the scene, always facing the camera: a tree, a puff, a health bar.", + params = { file = "file", height = "number", w = "number", h = "number", d = "number" }, + load = function(instance) + instance.sprite = spriteFor(instance.look.file) + nodeSetSprite(instance.node, instance.sprite, instance.look.height or 1) + nodeSetBillboard(instance.node, BILLBOARD_Y) + end, + size = function(instance) return instance.look.w or 1, instance.look.h or instance.look.height or 1, instance.look.d or 1 end, + draw = function() end +} + +AUTHOR.looks.text3d = { + help = "A line of text standing in the scene. The var text is what it says.", + params = { text = "string", height = "number" }, + load = function(instance) + instance.vars.text = instance.vars.text or instance.look.text or "" + instance.shown = instance.vars.text + nodeSetText(instance.node, instance.vars.text, instance.look.height or 0.5) + nodeSetBillboard(instance.node, BILLBOARD_Y) + end, + size = function(instance) return 1, instance.look.height or 0.5, 0.1 end, + draw = function(instance) + if instance.shown ~= instance.vars.text then + instance.shown = instance.vars.text + nodeSetText(instance.node, instance.vars.text, instance.look.height or 0.5) + end + end +} + + +AUTHOR.looks.grid = { + help = "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.", + params = { file = "file", tile = "number", columns = "number", map = "string" }, + load = function(instance) + local look = instance.look + + instance.sprite = spriteFor(look.file) + instance.rows = {} + for row in string.gmatch(tostring(look.map or ""), "[^;]+") do + local cells = {} + + for number in string.gmatch(row, "%d+") do + cells[#cells + 1] = tonumber(number) + end + instance.rows[#instance.rows + 1] = cells + end + end, + size = function(instance) + local tile = instance.look.tile or 32 + local width = 0 + + for _, row in ipairs(instance.rows or {}) do + width = math.max(width, #row) + end + + return width * tile, #(instance.rows or {}) * tile + end, + draw = function(instance) + local look = instance.look + local tile = look.tile or 32 + local columns = look.columns or 1 + local x, y = authorPosition(instance) + + for r, row in ipairs(instance.rows) do + for c, cell in ipairs(row) do + if cell > 0 then + local sx = ((cell - 1) % columns) * tile + local sy = math.floor((cell - 1) / columns) * tile + + spriteDrawGrid(instance.sprite, x + (c - 1) * tile, y + (r - 1) * tile, sx, sy, tile, tile) + end + end end end } @@ -157,160 +849,1382 @@ AUTHOR.looks.text = { -- ===== Behaviours ============================================================================= -- -- Each is a bundle over engine calls that already exist and are tested. A platformer is a --- checkbox over the character controller, not a reimplementation of one. +-- checkbox over the character controller, not a reimplementation of one. attach runs once when +-- the instance is made, step every frame with the seconds since the last one, and on(event) when +-- something happens to the instance. AUTHOR.behaviours.solid = { - help = "Immovable ground or a wall.", - params = {}, - attach = function(entity) - local w, h = authorSize(entity) + help = "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.", + params = { moving = "boolean" }, + attach = function(instance, params) + local w, h, d = authorUnscaledSize(instance) + local kind = params.moving and BODY_KINEMATIC or BODY_STATIC - bodyNew(entity.node, BODY_STATIC, SHAPE_BOX, w / 2, h / 2, w / 2) + 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) + end + instance.hasBody = true + end +} + +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" }, + attach = function(instance, params) + local w, h, d = authorUnscaledSize(instance) + + if params.shape == "sphere" then + bodyNew(instance.node, BODY_DYNAMIC, SHAPE_SPHERE, w / 2) + 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) + end + if params.mass then + bodySetMass(instance.node, params.mass) + end + if params.bounce then + bodySetBounce(instance.node, params.bounce) + end + if params.friction then + bodySetFriction(instance.node, params.friction) + end + if params.buoyancy then + bodySetBuoyancy(instance.node, params.buoyancy) + end + instance.hasBody = true + end +} + +AUTHOR.behaviours.trigger = { + help = "A volume that reports what enters and leaves it, as the enter and leave events. A checkpoint, a doorway, a prize.", + params = {}, + attach = function(instance) + local w, h, d = authorUnscaledSize(instance) + + 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) + end + bodySetTrigger(instance.node, true) + instance.hasBody = true + end +} + +AUTHOR.behaviours.character = { + help = "Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera.", + params = { speed = "number", jump = "number", radius = "number", height = "number" }, + attach = function(instance, params) + local w, h = authorSize(instance) + + instance.speed = params.speed or 4 + instance.jump = params.jump or 6 + instance.player = true + playerNew(instance.node, SHAPE_CAPSULE, params.radius or (w * PLAYER_RADIUS), params.height or h) + end, + step = function(instance) + local dx = instance.vars.dx or 0 + local dy = instance.vars.dy or 0 + local fx, fz, rx, rz = authorCameraAxes() + local vx = (rx * dx - fx * dy) * instance.speed + local vz = (rz * dx - fz * dy) * instance.speed + + playerMove(instance.node, vx, vz) + if ((dx ~= 0) or (dy ~= 0)) and instance.model then + local x, y, z = authorPosition(instance) + + nodeLookAt(instance.model, x - vx, y, z - vz) + end + end +} + +AUTHOR.behaviours.seek = { + help = "Moves straight toward a target -- an entity's id, or \"camera\" -- and raises arrived within stopAt of it.", + params = { target = "string", speed = "number", stopAt = "number", fly = "boolean" }, + attach = function(instance, params) + instance.seek = params + end, + step = function(instance, dt) + local p = instance.seek + local tx, ty, tz = authorTargetPosition(p.target) + + if tx == nil then + return + end + local x, y, z = authorPosition(instance) + local dx, dy, dz = tx - x, ty - y, tz - z + + if not p.fly then + dy = 0 + end + local far = math.sqrt(dx * dx + dy * dy + dz * dz) + + if far <= (p.stopAt or 1) then + if not instance.arrived then + instance.arrived = true + authorEvent("arrived", { self = instance }) + end + return + end + instance.arrived = false + local step = math.min((p.speed or 1) * dt, far) + + nodeSetPosition(instance.node, x + dx / far * step, y + dy / far * step, z + dz / far * step) + if instance.model then + nodeLookAt(instance.model, x - dx, y, z - dz) + end + end +} + +AUTHOR.behaviours.animator = { + help = "A clip per state: idle, walk, attack, hit, dead, or any state a rule sets. A clip that does not loop raises animationDone when it ends.", + params = { idle = "string", walk = "string", attack = "string", hit = "string", dead = "string", fade = "number", loop = "string" }, + attach = function(instance, params) + instance.animator = params + instance.playing = nil + end, + step = function(instance) + local p = instance.animator + local state = instance.vars.state + local clip = p[state] + + if instance.model == nil then + return + end + if (clip ~= nil) and (clip ~= instance.playing) then + local loops = (state == "idle") or (state == "walk") or (state == "run") or ((p.loop or ""):find(state, 1, true) ~= nil) + + animationPlay(instance.model, clip, loops, 1, p.fade or 0.15) + instance.playing = clip + instance.watch = not loops + elseif instance.watch and not animationIsPlaying(instance.model) then + instance.watch = false + authorEvent("animationDone", { self = instance, state = state }) + end + end +} + +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" }, + attach = function(instance, params) + instance.camera = params + instance.yaw = 0 + instance.pitch = 0 + cameraNode = instance.node + cameraSet(instance.node) + if params.mode == "rail" then + instance.rail = { t = 0, waiting = nil, passed = {} } + end + if (params.mode == "fixed") or (params.mode == nil) then + nodeLookAt(instance.node, params.lookX or 0, params.lookY or 0, params.lookZ or 0) + end + end, + step = function(instance, dt) + local p = instance.camera + + if p.mode == "follow" then + local tx, ty, tz = authorTargetPosition(p.target) + + if tx then + local x, y, z = authorPosition(instance) + local k = (p.lag and p.lag > 0) and math.min(1, dt / p.lag) or 1 + + nodeSetPosition(instance.node, x + (tx + (p.x or 0) - x) * k, y + (ty + (p.y or 3) - y) * k, z + (tz + (p.z or 6) - z) * k) + nodeLookAt(instance.node, tx, ty + (p.lookY or 0.5), tz) + end + elseif p.mode == "orbit" then + local tx, ty, tz = authorTargetPosition(p.target) + + if tx then + instance.yaw = instance.yaw - AUTHOR_MOUSE.dx * LOOK_SPEED + instance.pitch = math.max(-PITCH_LIMIT, math.min(PITCH_LIMIT, instance.pitch + AUTHOR_MOUSE.dy * LOOK_SPEED)) + local far = p.z or 6 + local yaw = math.rad(instance.yaw) + local pitch = math.rad(instance.pitch) + + nodeSetPosition(instance.node, tx + math.sin(yaw) * math.cos(pitch) * far, ty + (p.y or 1) + math.sin(pitch) * far, tz + math.cos(yaw) * math.cos(pitch) * far) + nodeLookAt(instance.node, tx, ty + (p.lookY or 0.5), tz) + end + elseif p.mode == "first" then + local target = authorTargetInstance(p.target) + + if target then + local tx, ty, tz = authorPosition(target) + + instance.yaw = instance.yaw - AUTHOR_MOUSE.dx * LOOK_SPEED + instance.pitch = math.max(-PITCH_LIMIT, math.min(PITCH_LIMIT, instance.pitch - AUTHOR_MOUSE.dy * LOOK_SPEED)) + nodeSetPosition(instance.node, tx, ty + (p.eye or 0.7), tz) + nodeSetRotation(instance.node, instance.pitch, instance.yaw, 0) + end + elseif p.mode == "rail" then + authorRailStep(instance, dt) + end end } AUTHOR.behaviours.platformer = { - help = "Runs, falls and jumps: the engine's character controller in 2D.", + help = "Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump.", params = { speed = "number", jump = "number" }, - attach = function(entity, params) - local w, h = authorSize(entity) + attach = function(instance, params) + local w, h = authorSize(instance) - entity.speed = params.speed or 200 - entity.jump = params.jump or 500 - playerNew(entity.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h) + instance.speed = params.speed or 200 + instance.jump = params.jump or 500 + instance.player = true + playerNew(instance.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h) end, - step = function(entity) + step = function(instance) -- The rules say which way; this clears it each frame so releasing a key stops the run. - playerMove(entity.node, entity.drive * entity.speed) - entity.drive = 0 + playerMove(instance.node, instance.vars.drive * instance.speed) + instance.vars.drive = 0 end } AUTHOR.behaviours.drift = { - help = "Moves steadily, for a cloud, a platform or a target.", + help = "Moves steadily, for a cloud, a platform, or a target.", params = { vx = "number", vy = "number" }, - attach = function(entity, params) - entity.vx = params.vx or 0 - entity.vy = params.vy or 0 + attach = function(instance, params) + instance.vars.vx = instance.vars.vx or params.vx or 0 + instance.vars.vy = instance.vars.vy or params.vy or 0 end, - step = function(entity, dt) - local x, y, z = nodeGetPosition(entity.node) + step = function(instance, dt) + local x, y, z = authorPosition(instance) - nodeSetPosition(entity.node, x + entity.vx * dt, y + entity.vy * dt, z) + nodeSetPosition(instance.node, x + instance.vars.vx * dt, y + instance.vars.vy * dt, z) + end +} + +AUTHOR.behaviours.keys = { + help = "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.", + params = { player = "number", left = "scancode", right = "scancode", up = "scancode", down = "scancode", fire = "scancode" }, + attach = function(instance, params) + instance.keys = { + left = keyValue(params.left), + right = keyValue(params.right), + up = keyValue(params.up), + down = keyValue(params.down), + fire = keyValue(params.fire) + } + instance.vars.dx = 0 + instance.vars.dy = 0 + end, + step = function(instance) + local k = instance.keys + local dx = 0 + local dy = 0 + + if AUTHOR_CONTROLS then + if k.left and authorKeyHeld(k.left) then dx = dx - 1 end + if k.right and authorKeyHeld(k.right) then dx = dx + 1 end + if k.up and authorKeyHeld(k.up) then dy = dy - 1 end + if k.down and authorKeyHeld(k.down) then dy = dy + 1 end + end + instance.vars.dx = dx + instance.vars.dy = dy + instance.vars.fire = AUTHOR_CONTROLS and (k.fire ~= nil) and authorKeyHeld(k.fire) + end +} + +AUTHOR.behaviours.mover = { + help = "Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.", + params = { speed = "number", clamp = "boolean" }, + attach = function(instance, params) + instance.moveSpeed = params.speed or 200 + instance.clamp = params.clamp + end, + step = function(instance, dt) + local x, y, z = authorPosition(instance) + local w, h = authorSize(instance) + + x = x + (instance.vars.dx or 0) * instance.moveSpeed * dt + y = y + (instance.vars.dy or 0) * instance.moveSpeed * dt + if (instance.vars.dx or 0) ~= 0 then + instance.vars.facing = (instance.vars.dx < 0) and "left" or "right" + end + if instance.clamp then + x = math.max(w / 2, math.min(overlayGetWidth() - w / 2, x)) + y = math.max(h / 2, math.min(overlayGetHeight() - h / 2, y)) + end + nodeSetPosition(instance.node, x, y, z) + end +} + +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" }, + attach = function(instance, params) + instance.shooter = params + instance.lastShot = -math.huge + end, + step = function(instance) + local p = instance.shooter + + if (p.auto or instance.vars.fire) and (authorTime() - instance.lastShot >= (p.rate or 0.2)) then + local x, y = authorPosition(instance) + + instance.lastShot = authorTime() + authorSpawn(p.spawns, x + (p.offsetX or 0), y + (p.offsetY or 0), 0, { owner = instance.id }) + end + end +} + +AUTHOR.behaviours.projectile = { + help = "Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies.", + params = { vx = "number", vy = "number", vz = "number", life = "number", aim = "boolean" }, + attach = function(instance, params) + instance.vars.vx = instance.vars.vx or params.vx or 0 + instance.vars.vy = instance.vars.vy or params.vy or (AUTHOR_3D and 0 or -400) + instance.vars.vz = instance.vars.vz or params.vz or 0 + instance.dies = authorTime() + (params.life or 5) + if params.aim and not AUTHOR_3D then + instance.vars.angle = math.deg(math.atan(instance.vars.vx, -instance.vars.vy)) + end + end, + step = function(instance, dt) + local x, y, z = authorPosition(instance) + + x = x + instance.vars.vx * dt + y = y + instance.vars.vy * dt + z = z + instance.vars.vz * dt + nodeSetPosition(instance.node, x, y, z) + if authorTime() > instance.dies then + authorDestroy(instance) + elseif AUTHOR_3D then + if (math.abs(x) > FAR_AWAY) or (math.abs(y) > FAR_AWAY) or (math.abs(z) > FAR_AWAY) then + authorDestroy(instance) + end + elseif (x < -SPAWN_MARGIN) or (y < -SPAWN_MARGIN) or (x > overlayGetWidth() + SPAWN_MARGIN) or (y > overlayGetHeight() + SPAWN_MARGIN) then + authorDestroy(instance) + end + end +} + +AUTHOR.behaviours.health = { + help = "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.", + params = { max = "number", keep = "boolean", ragdoll = "boolean", linger = "number" }, + attach = function(instance, params) + instance.vars.health = instance.vars.health or params.max or 1 + instance.keepDead = params.keep or params.ragdoll + instance.linger = params.linger or 4 + if params.ragdoll and instance.model then + ragdollNew(instance.model) + instance.ragdoll = true + end + end, + on = function(instance, event, data) + if (event == "death") and instance.ragdoll then + ragdollActivate(instance.model) + authorTimerStart(instance, "gone", instance.linger) + instance.isTarget = false + elseif (event == "timer") and (data.name == "gone") then + authorDestroy(instance) + end + end +} + +AUTHOR.behaviours.timer = { + help = "Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.", + params = { name = "string", after = "number", every = "number", start = "boolean" }, + attach = function(instance, params) + if params.start ~= false then + authorTimerStart(instance, params.name, params.after or params.every, params.every) + end + end +} + +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" }, + attach = function(instance, params) + instance.spawner = params + instance.nextAt = authorTime() + (params.every or 1) + instance.vars.made = 0 -- A var, so a rule can ask how many it has made. + instance.point = 0 + end, + step = function(instance) + local p = instance.spawner + + if (authorTime() < instance.nextAt) or (p.total and instance.vars.made >= p.total) then + return + end + if p.max and (authorCount(p.spawns) >= p.max) then + return + end + local x, y, z = authorPosition(instance) + + if p.points and #p.points > 0 then + local at + + if p.pick == "random" then + at = math.random(#p.points) + else + instance.point = (instance.point % #p.points) + 1 + at = instance.point + end + x = p.points[at].x or p.points[at][1] + y = p.points[at].y or p.points[at][2] + z = p.points[at].z or p.points[at][3] or z + end + instance.nextAt = authorTime() + (p.every or 1) + instance.vars.made = instance.vars.made + 1 + authorSpawn(p.spawns, x, y, z, { spawner = instance.id }) + end +} + +AUTHOR.behaviours.sound = { + help = "A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's.", + params = { spawn = "file", hit = "file", death = "file", pressed = "file", volume = "number" }, + attach = function(instance, params) + instance.sounds = params + if params.spawn then + authorPlaySound(params.spawn, params.volume) + end + end, + on = function(instance, event) + local file = instance.sounds[event] + + if file and (event ~= "spawn") then + authorPlaySound(file, instance.sounds.volume) + end + end +} + +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" }, + attach = function(instance, params) + instance.gun = params + instance.trigger = switchValue(params.trigger) + instance.vars.ammo = instance.vars.ammo or params.ammo or 6 + instance.vars.player = params.player or 1 + end +} + +AUTHOR.behaviours.target = { + help = "Something a gun can hit. In 2D its look's box; in 3D a body the size of its look, plus zones -- boxes on named bones, so a shot says which part it hit.", + params = { zones = "table" }, + attach = function(instance, params) + instance.isTarget = true + if AUTHOR_3D and not instance.hasBody then + -- The body sits on a child node raised by half the height, since a model stands on its + -- origin and a box centred on the feet would be half underground. + local w, h, d = authorSize(instance) + local box = nodeNew() + + nodeSetParent(box, instance.node) + nodeSetPosition(box, 0, h / 2, 0) + nodeSetName(box, "body") + bodyNew(box, BODY_KINEMATIC, SHAPE_BOX, w, h, d) + bodySetTrigger(box, true) + AUTHOR_BY_NODE[box] = instance + instance.nodes[#instance.nodes + 1] = box + end + for _, zone in ipairs(params.zones or {}) do + local bone = instance.model and nodeFind(zone.bone, instance.model) or nil + + if bone then + local box = nodeNew() + + nodeSetParent(box, bone) + nodeSetName(box, zone.name) + AUTHOR_BY_NODE[box] = instance + instance.nodes[#instance.nodes + 1] = box + instance.zones = instance.zones or {} + instance.zones[#instance.zones + 1] = { node = box, name = zone.name, reach = math.max(zone.w or 0.3, zone.h or 0.3, zone.d or 0.3) / 2 } + else + debugPrint("Author: " .. instance.id .. " has no bone called '" .. tostring(zone.bone) .. "' for zone " .. tostring(zone.name)) + end + end + end +} + +AUTHOR.behaviours.walker = { + help = "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.", + params = { speed = "number", radius = "number", follow = "string", every = "number", stopAt = "number" }, + attach = function(instance, params) + instance.walker = params + instance.repath = 0 + end, + step = function(instance) + local nav = authorRoomNav() + + if nav == nil then + return + end + if instance.agent == nil then + local x, y, z = authorPosition(instance) + + if AUTHOR_3D then + instance.navNode = instance.node + else + instance.navNode = nodeNew() + nodeSetPosition(instance.navNode, x * NAV_SCALE, 0, y * NAV_SCALE) + end + instance.agent = navAgentNew(nav.nav, instance.navNode, instance.walker.radius or nav.radius, nav.height, (instance.walker.speed or 120) * nav.scale) + AUTHOR_AGENTS[instance.agent] = instance + end + if not AUTHOR_3D then + local nx, _, nz = nodeGetPosition(instance.navNode) + + nodeSetPosition(instance.node, nx / NAV_SCALE, nz / NAV_SCALE, 0) + end + -- Following: the target's position is asked for again every so often, and the walk ends + -- within stopAt of it, where a seek would. + if instance.walker.follow and (authorTime() >= instance.repath) then + local tx, ty, tz = authorTargetPosition(instance.walker.follow) + + instance.repath = authorTime() + (instance.walker.every or 0.5) + if tx then + local x, y, z = authorPosition(instance) + local far = AUTHOR_3D and math.sqrt((tx - x) ^ 2 + (tz - z) ^ 2) or math.sqrt((tx - x) ^ 2 + (ty - y) ^ 2) + + if far > (instance.walker.stopAt or 1.5) then + authorWalkStart(instance, tx, ty, tz) + elseif instance.walking then + navAgentStop(instance.agent) + instance.walking = false + instance.vars.state = "idle" + authorEvent("arrived", { self = instance }) + end + end + end + if instance.walking then + if navAgentIsArrived(instance.agent) then + instance.walking = false + instance.vars.state = "idle" + authorEvent("arrived", { self = instance }) + else + local vx, vy, vz = navAgentGetVelocity(instance.agent) + + if (vx ~= 0) or (vz ~= 0) then + instance.vars.state = "walk" + if instance.model then + local x, y, z = authorPosition(instance) + + nodeLookAt(instance.model, x - vx, y, z - vz) + end + instance.vars.facing = (vx < 0) and "left" or "right" + end + end + end + end +} + +AUTHOR.behaviours.patrol = { + help = "Walks a list of points in turn -- a creep's lane, a guard's round -- looping or stopping, and raises patrolEnd at the last.", + params = { points = "table", loop = "boolean" }, + attach = function(instance, params) + instance.patrol = params + instance.leg = 0 + end, + step = function(instance) + local p = instance.patrol + + if (instance.agent == nil) or instance.walking or (p.points == nil) or (#p.points == 0) then + return + end + if instance.leg >= #p.points then + if p.loop then + instance.leg = 0 + elseif not instance.patrolDone then + instance.patrolDone = true + authorEvent("patrolEnd", { self = instance }) + return + else + return + end + end + instance.leg = instance.leg + 1 + local point = p.points[instance.leg] + + authorWalkStart(instance, point.x or point[1], point.y or point[2] or 0, point.z or point[3] or 0) + end +} + +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" }, + 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 + + if not instance.hasBody then + bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, d) + instance.hasBody = true + end + bodySetMass(instance.node, params.mass or 800) + vehicleNew(instance.node, kind) + if kind ~= VEHICLE_BOAT then + local wx = params.wheelX or (w / 2) + local wz = params.wheelZ or (d / 2 - 0.2) + local radius = params.radius or 0.35 + local wheels = (kind == VEHICLE_MOTORCYCLE) and { { 0, -wz }, { 0, wz } } or { { -wx, -wz }, { wx, -wz }, { -wx, wz }, { wx, wz } } + + for _, at in ipairs(wheels) do + local wheel = nodeNew() + + nodeSetParent(wheel, instance.node) + nodeSetPosition(wheel, at[1], -h / 2, at[2]) + instance.nodes[#instance.nodes + 1] = wheel + vehicleAddWheel(instance.node, wheel, radius, params.width or 0.2, params.suspension or 0.3) + end + if params.torque then + vehicleSetEngine(instance.node, params.torque, 6000) + end + elseif params.thrust then + vehicleSetThrust(instance.node, params.thrust, 0, -0.2, d / 2) + end + instance.vehicle = true + end, + step = function(instance) + local dx = instance.vars.dx or 0 + local dy = instance.vars.dy or 0 + + vehicleDrive(instance.node, -dy, dx, instance.vars.brake or 0) + instance.vars.speed = vehicleGetSpeed(instance.node) + end +} + +AUTHOR.behaviours.racer = { + help = "Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.", + params = { track = "track", throttle = "number", reach = "number" }, + attach = function(instance, params) + instance.racer = params + instance.leg = 1 + end, + step = function(instance) + local track = authorTrackNamed(instance.racer.track or "") + local points = track and track.points or {} + + if (#points == 0) or not instance.vehicle then + return + end + local point = points[instance.leg] + local x, y, z = authorPosition(instance) + local dx, dz = (point.x or 0) - x, (point.z or 0) - z + local far = math.sqrt(dx * dx + dz * dz) + + if far < (instance.racer.reach or 3) then + instance.leg = (instance.leg % #points) + 1 + return + end + -- The chassis' heading from its yaw; the steering is the signed angle to the point. + local _, yaw, _ = nodeGetRotation(instance.node) + local heading = math.rad(yaw) + local fx, fz = -math.sin(heading), -math.cos(heading) + local cross = fx * dz - fz * dx + local dot = fx * dx + fz * dz + local angle = math.atan(cross, dot) + + instance.vars.dx = math.max(-1, math.min(1, -angle * 2)) + instance.vars.dy = -(instance.racer.throttle or 0.6) * ((dot > 0) and 1 or 0.3) + end +} + +AUTHOR.behaviours.thrust = { + help = "A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.", + params = { force = "number", turn = "number" }, + attach = function(instance, params) + instance.thrust = params + end, + step = function(instance) + if not instance.hasBody then + return + end + local _, yaw, _ = nodeGetRotation(instance.node) + local heading = math.rad(yaw) + local push = -(instance.vars.dy or 0) * (instance.thrust.force or 500) + + bodyApplyForce(instance.node, -math.sin(heading) * push, 0, -math.cos(heading) * push) + bodySetAngularVelocity(instance.node, 0, -(instance.vars.dx or 0) * (instance.thrust.turn or 1.5), 0) + end +} + +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" }, + attach = function(instance, params) + instance.jointWanted = params + end, + step = function(instance) + -- Made on the first frame, once every body of the room exists. + local p = instance.jointWanted + + if p == nil then + return + end + instance.jointWanted = nil + local other = p.other and AUTHOR_BY_ID[p.other] or nil + local node = other and other.node or -1 + local x, y, z = authorPosition(instance) + + if p.type == "ball" then + instance.joint = jointBall(instance.node, node, p.ax or x, p.ay or y, p.az or z) + elseif p.type == "slider" then + instance.joint = jointSlider(instance.node, node, p.ax or x, p.ay or y, p.az or z, p.dx or 1, p.dy or 0, p.dz or 0) + else + instance.joint = jointHinge(instance.node, node, p.ax or x, p.ay or y, p.az or z, p.dx or 0, p.dy or 1, p.dz or 0) + end + end +} + +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" }, + attach = function(instance, params) + instance.turret = params + instance.lastShot = -math.huge + end, + step = function(instance) + local p = instance.turret + + if authorTime() - instance.lastShot < (p.rate or 1) then + return + end + local nearest, best = nil, p.range or 6 + + for _, target in ipairs(authorEach(p.targets or "")) do + local far = authorDistance3D(instance, target) + + if far < best then + nearest, best = target, far + end + end + if nearest == nil then + return + end + instance.lastShot = authorTime() + if p.spawns then + local x, y, z = authorPosition(instance) + local tx, ty, tz = authorPosition(nearest) + local speed = p.speed or 12 + local dx, dy, dz = tx - x, ty - y, tz - z + local far = math.max(0.001, math.sqrt(dx * dx + dy * dy + dz * dz)) + + authorSpawn(p.spawns, x, y, z, { vars = { vx = dx / far * speed, vy = dy / far * speed, vz = dz / far * speed }, owner = instance.id }) + else + authorDamage(nearest, p.damage or 1) + end + authorBehaviourEvent(instance, "fire") + end +} + +AUTHOR.behaviours.branching = { + help = "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.", + params = { track = "track" }, + attach = function(instance, params) + instance.branching = params + instance.branchAt = nil + end, + step = function(instance) + local track = authorTrackNamed(instance.branching.track or "") + local frame = discGetFrame() + + if track == nil then + return + end + for _, branch in ipairs(track.branches or {}) do + if (frame >= branch.from) and (frame < branch.to) then + if instance.branchAt ~= branch then + instance.branchAt = branch + instance.decided = false + authorEvent("branchOpen", { self = instance, move = branch.move }) + end + if not instance.decided then + local key = keyValue(branch.move) + local switch = switchValue(branch.switch) + + if (key and authorKeyPressed(key)) or (switch and pressedNow[switch]) then + instance.decided = true + if branch.success then + discSkipToFrame(branch.success) + end + authorEvent("branchTaken", { self = instance, move = branch.move }) + end + end + elseif (instance.branchAt == branch) and (frame >= branch.to) and not instance.decided then + instance.decided = true + if branch.fail then + discSkipToFrame(branch.fail) + end + authorEvent("branchMissed", { self = instance, move = branch.move }) + end + end + end +} + +AUTHOR.behaviours.spin = { + help = "Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second.", + params = { rate = "number" }, + attach = function(instance, params) + instance.spinRate = params.rate or 90 + instance.vars.angle = instance.vars.angle or 0 + end, + step = function(instance, dt) + if AUTHOR_3D then + local rx, ry, rz = nodeGetRotation(instance.node) + + nodeSetRotation(instance.node, rx, (ry + instance.spinRate * dt) % 360, rz) + else + instance.vars.angle = (instance.vars.angle + instance.spinRate * dt) % 360 + end + end +} + +AUTHOR.behaviours.frames = { + help = "Steps a sprite look's frame: a range of frames per state, as \"idle=1-1, walk=2-5\", at a rate a second. A sheet drawn facing both ways names the other way's frames stateLeft or stateRight (walkLeft), used while the var facing is that way instead of mirroring the look.", + params = { fps = "number", states = "string" }, + attach = function(instance, params) + instance.frameRanges = {} + for state, from, to in string.gmatch(tostring(params.states or ""), "(%w+)%s*=%s*(%d+)%s*-%s*(%d+)") do + instance.frameRanges[state] = { tonumber(from), tonumber(to) } + end + instance.fps = params.fps or 8 + instance.frameClock = 0 + instance.vars.frame = instance.vars.frame or 1 + end, + step = function(instance, dt) + local state = instance.vars.state or "idle" + local range = instance.frameRanges[state] + local facing = instance.vars.facing + local faced = facing and instance.frameRanges[state .. facing:sub(1, 1):upper() .. facing:sub(2)] + + -- Frames drawn the way the instance faces are used over the plain ones, and the look is + -- told, so it does not mirror them as well. + instance.drawnFacing = faced and facing or nil + range = faced or range or instance.frameRanges.idle + if range == nil then + return + end + instance.frameClock = instance.frameClock + dt * instance.fps + local count = range[2] - range[1] + 1 + local frame = range[1] + (math.floor(instance.frameClock) % count) + + instance.vars.frame = frame + end +} + +AUTHOR.behaviours.water = { + help = "Fills a mesh look with water: its top is the surface, and bodies inside float or sink by their buoyancy.", + params = { density = "number", drag = "number" }, + attach = function(instance, params) + local w, h, d = authorSize(instance) + + bodyNew(instance.node, BODY_STATIC, SHAPE_BOX, w, h, d) + bodySetWater(instance.node, params.density or 1, params.drag or 0.5) + instance.hasBody = true + end +} + +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" }, + attach = function(instance, params) + softNew(instance.node, (params.type == "cloth") and SOFT_CLOTH or SOFT_BODY) + if params.pressure then + softSetPressure(instance.node, params.pressure) + end + if params.stiffness then + softSetStiffness(instance.node, params.stiffness) + end + if params.mass then + softSetMass(instance.node, params.mass) + end + instance.soft = true + end +} + +AUTHOR.behaviours.hotspot = { + help = "Something a verb can be used on: a name for the sentence line, a place to walk to first, and a polygon (x,y pairs) when its look's box will not do.", + params = { name = "string", polygon = "table", walkX = "number", walkY = "number", walkZ = "number" }, + attach = function(instance, params) + instance.hotspot = params + instance.polygon = authorNumbers(params.polygon) + if AUTHOR_3D and not instance.hasBody then + local w, h, d = authorSize(instance) + local box = nodeNew() + + nodeSetParent(box, instance.node) + nodeSetPosition(box, 0, h / 2, 0) + bodyNew(box, BODY_KINEMATIC, SHAPE_BOX, w, h, d) + bodySetTrigger(box, true) + AUTHOR_BY_NODE[box] = instance + instance.nodes[#instance.nodes + 1] = box + end + end +} + +AUTHOR.behaviours.hitbox = { + help = "The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none.", + params = { track = "track" }, + attach = function(instance, params) + instance.hitTrack = params.track + instance.isTarget = true end } +-- ===== Events ================================================================================= +-- +-- What a rule can be triggered by. filter names the parameters a rule may give to narrow it; +-- the runtime matches them against the event's context. + +AUTHOR.events.frame = { help = "Every frame.", filter = {} } +AUTHOR.events.roomStart = { help = "A room has been entered.", filter = { room = "room" } } +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.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.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.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 } +AUTHOR.events.said = { help = "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.", filter = { verb = "string", noun = "string", second = "string" }, exclusive = true } +AUTHOR.events.branchOpen = { help = "A branch's window opened; event.move says what it wants.", filter = {} } +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" } } + + -- ===== Conditions ============================================================================= -- --- Every entry emits a Lua expression. The helpers they call are further down; keeping the test --- in a named function rather than inlining it is what makes the generated game readable. +-- Every entry emits a Lua expression. Parameters arrive as Lua fragments. AUTHOR.conditions.keyHeld = { help = "A key is down.", params = { key = "scancode" }, - emit = function(p) return string.format("authorKeyHeld(SCANCODE.%s.value)", p.key) end + emit = function(p) return "authorKeyHeld(" .. p.key .. ")" end } AUTHOR.conditions.switchHeld = { - help = "A pad or gun switch is down.", + help = "A pad, gun, or mouse switch is down.", params = { switch = "switch" }, - emit = function(p) return string.format("authorSwitchHeld(%s)", p.switch) end + emit = function(p) return "authorSwitchHeld(" .. p.switch .. ")" end +} + +AUTHOR.conditions.keyPressed = { + help = "A key went down this frame.", + params = { key = "scancode" }, + emit = function(p) return "authorKeyPressed(" .. p.key .. ")" end +} + +AUTHOR.conditions.test = { + help = "An expression is true: self.health <= 0, count(\"zombie\") == 0, score > 1000.", + params = { expr = "expression" }, + emit = function(p) return "(" .. p.expr .. ")" end } AUTHOR.conditions.timeBetween = { help = "The game is between two moments, in seconds.", params = { from = "number", to = "number" }, - emit = function(p) return string.format("authorTime() >= %s and authorTime() < %s", p.from, p.to) end + emit = function(p) return "(authorTime() >= " .. p.from .. " and authorTime() < " .. p.to .. ")" end } AUTHOR.conditions.discBetween = { help = "The disc is between two frames -- the window a QTE is answered in.", params = { from = "number", to = "number" }, - emit = function(p) return string.format("authorDiscBetween(%s, %s)", p.from, p.to) end + emit = function(p) return "authorDiscBetween(" .. p.from .. ", " .. p.to .. ")" end } AUTHOR.conditions.onGround = { - help = "An entity has ground under it.", + help = "A platformer has ground under it.", params = { entity = "entity" }, - emit = function(p) return string.format("authorOnGround(%q)", p.entity) end + emit = function(p) return "authorOnGround(" .. p.entity .. ")" end } AUTHOR.conditions.touching = { - help = "Two entities overlap.", + help = "Two instances overlap.", params = { entity = "entity", other = "entity" }, - emit = function(p) return string.format("authorTouching(%q, %q)", p.entity, p.other) end + emit = function(p) return "authorTouching(" .. p.entity .. ", " .. p.other .. ")" end } AUTHOR.conditions.below = { - help = "An entity has fallen past a line -- a pit, or the bottom of the screen.", + help = "An instance has fallen past a line -- a pit, or the bottom of the screen.", params = { entity = "entity", y = "number" }, - emit = function(p) return string.format("authorBelow(%q, %s)", p.entity, p.y) end + emit = function(p) return "(authorY(" .. p.entity .. ") > " .. p.y .. ")" end } -AUTHOR.conditions.flagSet = { - help = "A named flag the rules set themselves.", - params = { flag = "string" }, - emit = function(p) return string.format("AUTHOR_FLAG[%q] == true", p.flag) end +AUTHOR.conditions.inState = { + help = "An instance's state is this.", + params = { entity = "entity", state = "state" }, + emit = function(p) return "authorInState(" .. p.entity .. ", " .. p.state .. ")" end +} + +AUTHOR.conditions.pointerIn = { + help = "A player's pointer is over an instance.", + params = { entity = "entity", player = "number" }, + emit = function(p) return "authorPointerIn(" .. p.entity .. ", " .. p.player .. ")" end +} + +AUTHOR.conditions.pointerOffscreen = { + help = "A player's pointer is off the picture, which is how a light gun reloads.", + params = { player = "number" }, + emit = function(p) return "authorPointerOffscreen(" .. p.player .. ")" end +} + +AUTHOR.conditions.onScreen = { + help = "An instance's box is at least partly on the overlay.", + params = { entity = "entity" }, + emit = function(p) return "authorOnScreen(" .. p.entity .. ")" end +} + +AUTHOR.conditions.every = { + help = "True once every so many seconds, for a rule that fires steadily.", + params = { seconds = "number", tag = "string" }, + emit = function(p) return "authorEvery(" .. p.seconds .. ", " .. p.tag .. ")" end +} + +AUTHOR.conditions.chance = { + help = "True with a probability, 0 to 1.", + params = { p = "number" }, + emit = function(p) return "(math.random() < " .. p.p .. ")" end +} + +AUTHOR.conditions.hitPart = { + help = "The shot landed on a named zone of the target: head, weakspot.", + params = { part = "string" }, + emit = function(p) return "(event[\"part\"] == " .. p.part .. ")" end +} + +AUTHOR.conditions.waiting = { + help = "A rail camera is held at a stop.", + params = { entity = "entity" }, + emit = function(p) return "authorWaiting(" .. p.entity .. ")" end +} + +AUTHOR.conditions.has = { + help = "The inventory holds an item.", + params = { item = "string" }, + emit = function(p) return "authorHas(" .. p.item .. ")" end +} + +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 } AUTHOR.conditions.once = { - help = "Only the first time this rule would run.", - params = { tag = "string" }, - emit = function(p) return string.format("authorOnce(%q)", p.tag) end + 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 } -- ===== Actions ================================================================================ -- --- Every entry emits a Lua statement. +-- Every entry emits a Lua statement. waits marks the ones that take time: a rule with any of +-- them runs as a coroutine, and the action yields until it is done. AUTHOR.actions.run = { help = "Drive a platformer left (-1) or right (1) this frame.", params = { entity = "entity", direction = "number" }, - emit = function(p) return string.format("authorRun(%q, %s)", p.entity, p.direction) end + emit = function(p) return "authorRun(" .. p.entity .. ", " .. p.direction .. ")" end } AUTHOR.actions.jump = { help = "Ask a platformer to jump.", params = { entity = "entity" }, - emit = function(p) return string.format("authorJump(%q)", p.entity) end + emit = function(p) return "authorJump(" .. p.entity .. ")" end } AUTHOR.actions.moveTo = { - help = "Put an entity somewhere.", - params = { entity = "entity", x = "number", y = "number" }, - emit = function(p) return string.format("authorMoveTo(%q, %s, %s)", p.entity, p.x, p.y) end + 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 } AUTHOR.actions.setText = { - help = "Change what a text entity says.", + help = "Change what a text instance says.", params = { entity = "entity", text = "expression" }, - emit = function(p) return string.format("authorSetText(%q, %s)", p.entity, p.text) end + emit = function(p) return "authorSetVar(" .. p.entity .. ", \"text\", tostring(" .. p.text .. "))" end } AUTHOR.actions.show = { - help = "Show or hide an entity.", + help = "Show or hide an instance.", params = { entity = "entity", visible = "boolean" }, - emit = function(p) return string.format("authorShow(%q, %s)", p.entity, tostring(p.visible)) end + emit = function(p) return "authorShow(" .. p.entity .. ", " .. p.visible .. ")" end } AUTHOR.actions.addScore = { help = "Add to the score.", params = { amount = "number" }, - emit = function(p) return string.format("AUTHOR_SCORE = AUTHOR_SCORE + %s", p.amount) end + emit = function(p) return "authorAddVar(nil, \"score\", " .. p.amount .. ")" end } -AUTHOR.actions.setFlag = { - help = "Set or clear a named flag.", - params = { flag = "string", value = "boolean" }, - emit = function(p) return string.format("AUTHOR_FLAG[%q] = %s", p.flag, tostring(p.value)) end +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 +} + +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 +} + +AUTHOR.actions.setState = { + help = "Change an instance's state.", + params = { entity = "entity", state = "state" }, + emit = function(p) return "authorSetVar(" .. p.entity .. ", \"state\", " .. p.state .. ")" end +} + +AUTHOR.actions.damage = { + help = "Take from an instance's health; at zero it dies.", + params = { entity = "entity", amount = "number" }, + emit = function(p) return "authorDamage(" .. p.entity .. ", " .. p.amount .. ")" end +} + +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 +} + +AUTHOR.actions.destroy = { + help = "Remove an instance.", + params = { entity = "entity" }, + emit = function(p) return "authorDestroy(" .. p.entity .. ")" end +} + +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 +} + +AUTHOR.actions.playMusic = { + help = "Play a track, looping.", + params = { file = "file" }, + emit = function(p) return "authorPlayMusic(" .. p.file .. ")" end +} + +AUTHOR.actions.stopMusic = { + help = "Stop the track that is playing.", + params = {}, + emit = function() return "authorStopMusic()" end +} + +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 +} + +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" }, + waits = true, + emit = function(p) return "authorSay(" .. p.text .. ", " .. (p.seconds or "nil") .. ")" end +} + +AUTHOR.actions.wait = { + help = "Wait some seconds before the next action.", + params = { seconds = "number" }, + waits = true, + emit = function(p) return "authorWaitSeconds(" .. p.seconds .. ")" end +} + +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 +} + +AUTHOR.actions.timerStart = { + help = "Start a named timer on an instance.", + params = { entity = "entity", name = "string", after = "number" }, + emit = function(p) return "authorTimerStart(" .. p.entity .. ", " .. p.name .. ", " .. p.after .. ")" end +} + +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 +} + +AUTHOR.actions.reload = { + help = "Fill a gun's ammo.", + params = { entity = "entity" }, + emit = function(p) return "authorReload(" .. p.entity .. ")" end } AUTHOR.actions.discTo = { help = "Send the disc to a frame -- the branch a QTE takes.", params = { frame = "number" }, - emit = function(p) return string.format("discSkipToFrame(%s)", p.frame) end + emit = function(p) return "discSkipToFrame(" .. p.frame .. ")" end +} + +AUTHOR.actions.gameOver = { + help = "End the game: rules stop, the gameOver event fires, restart begins again.", + params = {}, + emit = function() return "authorGameOver()" end +} + +AUTHOR.actions.restart = { + help = "Begin the game again from its first room.", + params = {}, + emit = function() return "authorRestart()" end +} + +AUTHOR.actions.pathNext = { + help = "Send a rail camera on from its stop.", + params = { entity = "entity" }, + emit = function(p) return "authorPathNext(" .. p.entity .. ")" end +} + +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 +} + +AUTHOR.actions.lookAt = { + help = "Turn an instance toward another.", + params = { entity = "entity", target = "entity" }, + emit = function(p) return "authorLookAt(" .. p.entity .. ", " .. p.target .. ")" end +} + +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 +} + +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 +} + +AUTHOR.actions.cameraCut = { + help = "Draw the scene from another camera instance.", + params = { entity = "entity" }, + emit = function(p) return "authorCameraCut(" .. p.entity .. ")" end +} + +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 +} + +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" }, + waits = true, + emit = function(p) return "authorWalkTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. (p.z or "0") .. ")" end +} + +AUTHOR.actions.walkToPointer = { + help = "Send a walker where the player's pointer is, and wait.", + params = { entity = "entity", player = "number" }, + waits = true, + emit = function(p) return "authorWalkToPointer(" .. p.entity .. ", " .. (p.player or "1") .. ")" end +} + +AUTHOR.actions.walkToHotspot = { + help = "Send a walker to a hotspot's walk point (the one the verb was used on, with self), and wait.", + params = { entity = "entity", target = "entity" }, + waits = true, + emit = function(p) return "authorWalkToHotspot(" .. p.entity .. ", " .. p.target .. ")" end +} + +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 +} + +AUTHOR.actions.give = { + help = "Put an item in the inventory.", + params = { item = "string" }, + emit = function(p) return "authorGive(" .. p.item .. ")" end +} + +AUTHOR.actions.take = { + help = "Take an item out of the inventory.", + params = { item = "string" }, + emit = function(p) return "authorTake(" .. p.item .. ")" end +} + +AUTHOR.actions.setVerb = { + help = "Choose the verb the next click uses.", + params = { verb = "string" }, + emit = function(p) return "authorSetVerb(" .. p.verb .. ")" end +} + +AUTHOR.actions.nextVerb = { + help = "Cycle to the next verb in the game's list.", + params = {}, + emit = function() return "authorNextVerb()" end +} + +AUTHOR.actions.useItem = { + help = "Choose an inventory item for the next click: \"use key on ...\".", + params = { item = "string" }, + emit = function(p) return "authorUseItem(" .. p.item .. ")" end +} + +AUTHOR.actions.talk = { + help = "Run a dialogue from the game's dialogues, and wait for it to end.", + params = { dialogue = "string" }, + waits = true, + emit = function(p) return "authorTalk(" .. p.dialogue .. ")" end +} + +AUTHOR.actions.fade = { + help = "Fade the picture to black, or back (with out false), and wait.", + params = { seconds = "number", out = "boolean" }, + waits = true, + emit = function(p) return "authorFade(" .. (p.seconds or tostring(FADE_DEFAULT)) .. ", " .. (p.out or "true") .. ")" 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 +} + +AUTHOR.actions.loadGame = { + help = "Bring a slot back.", + params = { slot = "number" }, + emit = function(p) return "authorLoadGame(" .. (p.slot or "1") .. ")" end +} + +AUTHOR.actions.die = { + help = "The Sierra death: a message, then the game starts over.", + params = { text = "expression" }, + waits = true, + emit = function(p) return "authorDie(" .. p.text .. ")" end +} + +AUTHOR.actions.submitScore = { + help = "Send the score to the master service's board for this game, queued until it can go.", + params = { board = "string" }, + emit = function(p) return "authorSubmitScore(" .. (p.board or '"default"') .. ")" end +} + +AUTHOR.actions.credit = { + help = "A coin: one more credit.", + params = {}, + emit = function() return "authorAddVar(nil, \"credits\", 1)" end } AUTHOR.actions.lua = { @@ -320,29 +2234,169 @@ AUTHOR.actions.lua = { } --- ===== The runtime the generated Lua calls ==================================================== +-- ===== Instances ============================================================================== -AUTHOR_SCORE = 0 -AUTHOR_FLAG = {} +local function nextId(kind) + local id = kind .. "#" .. nextSerial -local onceSeen = {} -local lastTime = 0 + nextSerial = nextSerial + 1 - --- Where an entity is, in overlay coordinates. Everything is a node, whether or not the scene is --- drawing: nodes exist without a GPU, which is what lets a 2D game run on a machine with none. -function authorPosition(entity) - local x, y = nodeGetPosition(entity.node) - - return x, y + return id end +local function copyOf(value) + local out + + if type(value) ~= "table" then + return value + end + out = {} + for key, item in pairs(value) do + out[key] = copyOf(item) + end + + return out +end + + +-- 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] + local instance + + if def == nil then + debugPrint("Author: no kind called '" .. tostring(kind) .. "'") + return nil + end + overrides = overrides or {} + instance = { + id = overrides.id or nextId(kind), + kind = kind, + def = def, + look = copyOf(def.look or { kind = "none" }), + behaviours = def.behaviours or {}, + vars = copyOf(def.vars or {}), + visible = true, + alive = true, + node = nodeNew(), + nodes = {} + } + for key, value in pairs(overrides.vars or {}) do + 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 + instance.vars[key] = copyOf(value) + end + end + instance.vars.drive = 0 + instance.vars.state = instance.vars.state or "idle" + nodeSetPosition(instance.node, x or 0, y or 0, z or 0) + if overrides.rx or overrides.ry or overrides.rz then + nodeSetRotation(instance.node, overrides.rx or 0, overrides.ry or 0, overrides.rz or 0) + end + -- A scale on the entity scales its node, and with it its look, its body (bodies take the node's + -- scale), and how big it counts as. + instance.scale = overrides.scale or 1 + if instance.scale ~= 1 then + nodeSetScale(instance.node, instance.scale) + end + if overrides.rz and not AUTHOR_3D then + instance.vars.angle = overrides.rz + end + + local look = AUTHOR.looks[instance.look.kind] + + if look == nil then + debugPrint("Author: no look called '" .. tostring(instance.look.kind) .. "' on " .. instance.id .. "; drawing a box") + instance.look.kind = "box" + look = AUTHOR.looks.box + end + if look.load then + look.load(instance) + end + AUTHOR_LIVE[#AUTHOR_LIVE + 1] = instance + AUTHOR_BY_ID[instance.id] = instance + AUTHOR_BY_NODE[instance.node] = instance + for _, b in ipairs(instance.behaviours) do + local behaviour = AUTHOR.behaviours[b.kind] + + if behaviour == nil then + debugPrint("Author: no behaviour called '" .. tostring(b.kind) .. "'") + elseif behaviour.attach then + behaviour.attach(instance, b) + end + end + + return instance +end + + +function authorSpawn(kind, x, y, z, overrides) + local instance = make(kind, x, y, z, overrides) + + if instance ~= nil then + authorEvent("spawn", { self = instance }) + end + + return instance +end + + +-- Removes an instance now. Its node goes with it, and any body or player on the node. +function authorDestroy(instance) + if (instance == nil) or not instance.alive then + return + end + instance.alive = false + AUTHOR_BY_ID[instance.id] = nil + AUTHOR_BY_NODE[instance.node] = nil + for _, node in ipairs(instance.nodes) do + AUTHOR_BY_NODE[node] = nil + end + if instance.ragdoll then + ragdollDelete(instance.model) + end + for at = #AUTHOR_LIVE, 1, -1 do + if AUTHOR_LIVE[at] == instance then + table.remove(AUTHOR_LIVE, at) + end + end + for at = #timers, 1, -1 do + if timers[at].instance == instance then + table.remove(timers, at) + end + end + if instance.emitter then + emitterDelete(instance.emitter) + end + if instance.ownSprite then + spriteUnload(instance.ownSprite) + end + if instance.agent then + navAgentDelete(instance.agent) + AUTHOR_AGENTS[instance.agent] = nil + end + if instance.navNode and (instance.navNode ~= instance.node) then + nodeDelete(instance.navNode) + end + nodeDelete(instance.node) +end + + +-- ===== The runtime the generated Lua calls ==================================================== + function authorKeyHeld(scancode) return AUTHOR_KEYS[scancode] == true end +function authorKeyPressed(scancode) + return pressedNow[scancode] == true +end + + function authorSwitchHeld(switch) return AUTHOR_SWITCHES[switch] == true end @@ -355,193 +2409,2111 @@ function authorDiscBetween(from, to) end -function authorOnGround(id) - return playerIsOnGround(AUTHOR_WORLD[id].node) +function authorOnGround(instance) + return (instance ~= nil) and instance.player and playerIsOnGround(instance.node) end -function authorBelow(id, y) - local _, ey = authorPosition(AUTHOR_WORLD[id]) - - return ey > y -end - - -function authorTouching(idA, idB) - local a = AUTHOR_WORLD[idA] - local b = AUTHOR_WORLD[idB] - local ax, ay = authorPosition(a) - local bx, by = authorPosition(b) - local aw, ah = authorSize(a) - local bw, bh = authorSize(b) - - return collideRects(ax - aw / 2, ay - ah / 2, aw, ah, bx - bw / 2, by - bh / 2, bw, bh) -end - - --- How big an entity is, for touching and for the body a behaviour gives it. A box says; a sprite --- is its image; a text look has no size of its own and counts as a point unless it was given one. -function authorSize(entity) - local look = entity.look - - if entity.sprite ~= nil then - return spriteGetWidth(entity.sprite), spriteGetHeight(entity.sprite) - end - - return look.w or 0, look.h or 0 -end - - --- True the first time only. Rules run every frame, so anything that should happen once -- a door --- opening, a score awarded -- needs this rather than a flag the author has to remember to clear. -function authorOnce(tag) - if onceSeen[tag] then +function authorTouching(a, b) + if (a == nil) or (b == nil) then return false end - onceSeen[tag] = true + if AUTHOR_3D then + local ax, ay, az = authorPosition(a) + local bx, by, bz = authorPosition(b) + local aw, ah, ad = authorSize(a) + local bw, bh, bd = authorSize(b) + + return (math.abs(ax - bx) * 2 < aw + bw) and (math.abs(ay - by) * 2 < ah + bh) and (math.abs(az - bz) * 2 < (ad or aw) + (bd or bw)) + end + local ax, ay, aw, ah = authorBounds(a) + local bx, by, bw, bh = authorBounds(b) + + return collideRects(ax, ay, aw, ah, bx, by, bw, bh) +end + + +function authorInState(instance, state) + return (instance ~= nil) and (instance.vars.state == state) +end + + +function authorOnScreen(instance) + if instance == nil then + return false + end + local x, y, w, h = authorBounds(instance) + + return collideRects(x, y, w, h, 0, 0, overlayGetWidth(), overlayGetHeight()) +end + + +-- True the first time only, per instance and room by default; "game" makes it once ever. +function authorOnce(tag, instance, scope) + local key = tag + + if scope ~= "game" then + key = (AUTHOR_ROOM and AUTHOR_ROOM.name or "") .. "/" .. (instance and instance.id or "") .. "/" .. tag + end + if onceSeen[key] then + return false + end + onceSeen[key] = true return true end -function authorRun(id, direction) - AUTHOR_WORLD[id].drive = direction +local everyLast = {} + +function authorEvery(seconds, tag) + local now = authorTime() + + if (everyLast[tag] == nil) or (now - everyLast[tag] >= seconds) then + everyLast[tag] = now + return true + end + + return false end -function authorJump(id) - local entity = AUTHOR_WORLD[id] - - playerJump(entity.node, entity.jump) +function authorRun(instance, direction) + if instance then + instance.vars.drive = direction + end end -function authorMoveTo(id, x, y) - local entity = AUTHOR_WORLD[id] +function authorJump(instance) + if instance and instance.player then + playerJump(instance.node, instance.jump) + end +end - if entity.player then - playerSetPosition(entity.node, x, y, 0) + +function authorMoveTo(instance, x, y, z) + if instance == nil then + return + end + if instance.player then + playerSetPosition(instance.node, x, y, z or 0) else - nodeSetPosition(entity.node, x, y, 0) + nodeSetPosition(instance.node, x, y, z or 0) end end -function authorSetText(id, text) - AUTHOR_WORLD[id].text = tostring(text) +function authorShow(instance, visible) + if instance then + instance.visible = visible + end end -function authorShow(id, visible) - AUTHOR_WORLD[id].visible = visible +-- A var on an instance, or on the game with nil. +function authorSetVar(instance, name, value) + if instance == nil then + AUTHOR_VARS[name] = value + elseif instance.alive then + instance.vars[name] = value + end end --- Builds one entity from its description. Called by the generated game, once each, at startup. -function authorMake(description) - local entity = { - id = description.id, - look = description.look, - behaviours = description.behaviours or {}, - text = (description.look and description.look.text) or "", - visible = true, - drive = 0, - node = nodeNew() - } - local look = AUTHOR.looks[entity.look.kind] +function authorAddVar(instance, name, amount) + local vars = instance and instance.vars or AUTHOR_VARS - nodeSetPosition(entity.node, description.x or 0, description.y or 0, 0) - if look == nil then - -- A look that does not exist would take the game down when it came to be drawn; a box - -- is what it wears instead, and the console says which entity it was. - debugPrint("Author: no look called '" .. tostring(entity.look.kind) .. "' on " .. tostring(entity.id) .. "; drawing a box") - entity.look.kind = "box" - entity.look.w = entity.look.w or 20 - entity.look.h = entity.look.h or 20 - look = AUTHOR.looks.box + vars[name] = (vars[name] or 0) + amount +end + + +function authorDamage(instance, amount) + if (instance == nil) or not instance.alive then + return end - if look.load then - look.load(entity) + instance.vars.health = (instance.vars.health or 1) - amount + authorBehaviourEvent(instance, "hit") + if instance.vars.health <= 0 then + instance.vars.state = "dead" + authorBehaviourEvent(instance, "death") + authorEvent("death", { self = instance }) + if not instance.keepDead then + authorDestroy(instance) + end end - AUTHOR_WORLD[entity.id] = entity - AUTHOR_ORDER[#AUTHOR_ORDER + 1] = entity +end - for _, b in ipairs(entity.behaviours) do - local kind = AUTHOR.behaviours[b.kind] - if kind == nil then - debugPrint("Author: no behaviour called '" .. tostring(b.kind) .. "'") +-- Whether a sprite look is drawn mirrored: its art faces one way (faces: right unless said), the +-- instance faces the other, and its sheet has no frames drawn that way (the frames behaviour says +-- so by drawnFacing). Facing up or down never mirrors. +function authorMirrored(instance) + local faces = instance.look.faces or "right" + local opposite = (faces == "right") and "left" or ((faces == "left") and "right" or nil) + + if (opposite == nil) or (instance.vars.facing ~= opposite) then + return false + end + + return instance.drawnFacing ~= opposite +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)) +end + + +function authorPlayMusic(file) + if music then + musicStop(music) + end + music = musicLoad(file) + musicPlay(music, -1) +end + + +function authorStopMusic() + if music then + musicStop(music) + end +end + + +function authorEmit(instance, count, r, g, b, speed) + if instance == nil then + return + end + local x, y = authorPosition(instance) + + if instance.emitter == nil then + instance.emitter = emitterNew() + emitterSetLife(instance.emitter, 0.3, 0.7) + emitterSetSpread(instance.emitter, 360) + emitterSetSize(instance.emitter, 6, 1) + end + emitterSetPosition(instance.emitter, x, y) + emitterSetColor(instance.emitter, r, g, b, 255, r, g, b, 0) + emitterSetSpeed(instance.emitter, speed * 0.5, speed) + emitterBurst(instance.emitter, count) +end + + +function authorFlash(r, g, b, seconds) + flashLine = { r = r, g = g, b = b, seconds = seconds, till = authorTime() + seconds } +end + + +function authorTimerStart(instance, name, after, every) + if instance == nil then + return + end + timers[#timers + 1] = { instance = instance, name = name, at = authorTime() + (after or 0), every = every } +end + + +function authorReload(instance) + if instance and instance.gun then + instance.vars.ammo = instance.gun.ammo or 6 + authorBehaviourEvent(instance, "reload") + end +end + + +function authorGameOver() + if gameOver then + return + end + gameOver = true + -- The results card, and the best score kept for next time. + local best = math.max(AUTHOR_VARS.best or 0, AUTHOR_VARS.score or 0) + + AUTHOR_VARS.best = best + saveSet(BEST_KEY, best) + results = { score = AUTHOR_VARS.score or 0, best = best } + authorEvent("gameOver", {}) +end + + +-- The master service's client is loaded when a score is first sent, and pumped from then on. A +-- test may supply masterSubmitScore itself. +function authorSubmitScore(board) + if not masterReady then + if masterSubmitScore == nil then + dofile("Singe/Net.singe") + dofile("Singe/Master.singe") + masterLoad() + end + masterReady = true + end + masterSubmitScore(singeGetGameId(), AUTHOR_VARS.score or 0, board) +end + + +local function updateBezel() + if bezel == nil then + return + end + local shows = { { "score", 1 }, { "lives", 1 }, { "score2", 2 }, { "lives2", 2 } } + + for _, item in ipairs(shows) do + local value = AUTHOR_VARS[item[1]] + + if (value ~= nil) and (bezel.shown[item[1]] ~= value) then + bezel.shown[item[1]] = value + if item[1]:sub(1, 5) == "score" then + scoreBezelScore(item[2], value) + else + scoreBezelLives(item[2], value) + end + end + end + if (AUTHOR_VARS.credits ~= nil) and (bezel.shown.credits ~= AUTHOR_VARS.credits) then + bezel.shown.credits = AUTHOR_VARS.credits + scoreBezelCredits(AUTHOR_VARS.credits) + end +end + + +-- ===== The scene ============================================================================== + +-- Where a target named in a behaviour is: "camera", an entity's id, or nothing. +function authorTargetInstance(name) + if name == "camera" then + return cameraNode and AUTHOR_BY_NODE[cameraNode] or nil + end + + return AUTHOR_BY_ID[name or ""] +end + + +function authorTargetPosition(name) + local target = authorTargetInstance(name) + + if target == nil then + return nil + end + + return authorPosition(target) +end + + +-- The camera's forward and right on the ground, for a character that moves relative to the view. +-- Without a camera, world axes: forward is -Z. +function authorCameraAxes() + local camera = cameraNode and AUTHOR_BY_NODE[cameraNode] or nil + + if (camera == nil) or (camera.camera == nil) then + return 0, -1, 1, 0 + end + local yaw + + if camera.camera.mode == "first" or camera.camera.mode == "orbit" then + yaw = math.rad(camera.yaw) + else + local x, _, z = authorPosition(camera) + local tx, _, tz = authorTargetPosition(camera.camera.target) + + if tx == nil then + return 0, -1, 1, 0 + end + yaw = math.atan(x - tx, z - tz) + end + -- Forward is away from the camera, right is ninety degrees round from it. + local fx, fz = -math.sin(yaw), -math.cos(yaw) + + return fx, fz, -fz, fx +end + + +-- The instance a node belongs to. Every node an instance owns is registered when it is made, so +-- this never asks the engine -- which matters because physics can report a node a rule has already +-- deleted, a prize destroyed as it is entered, and asking about that node would end the game. +function authorInstanceOf(node) + return node and AUTHOR_BY_NODE[node] or nil +end + + +function authorWaiting(instance) + return (instance ~= nil) and (instance.rail ~= nil) and (instance.rail.waiting ~= nil) +end + + +function authorPathNext(instance) + if instance and instance.rail then + instance.rail.waiting = nil + end +end + + +function authorPlayAnimation(instance, clip, loop) + if instance and instance.model then + animationPlay(instance.model, clip, loop) + instance.playing = clip + end +end + + +function authorLookAt(instance, target) + if instance and target then + local x, y, z = authorPosition(target) + + nodeLookAt(instance.model or instance.node, x, y, z) + end +end + + +function authorPush(instance, x, y, z, bone) + if instance == nil then + return + end + if instance.ragdoll and bone then + ragdollApplyImpulse(instance.model, bone, x, y, z) + elseif instance.hasBody then + bodyApplyImpulse(instance.node, x, y, z) + end +end + + +local shaking = nil + +function authorShake(amount, seconds) + shaking = { amount = amount, till = authorTime() + seconds } +end + + +function authorCameraCut(instance) + if instance then + cameraNode = instance.node + cameraSet(instance.node) + end +end + + +-- One step along a rail: the camera track's points are positions by time, each looking at a +-- point or an entity, and a point may be a stop that holds the rail until pathNext. +function authorRailStep(instance, dt) + local rail = instance.rail + local track = authorTrackNamed(instance.camera.track or "") + local points = track and track.points or {} + + if #points == 0 then + return + end + if rail.waiting == nil then + rail.t = rail.t + dt + end + local before, after + + for _, point in ipairs(points) do + if (point.at <= rail.t) and ((before == nil) or (point.at > before.at)) then + before = point + end + if (point.at >= rail.t) and ((after == nil) or (point.at < after.at)) then + after = point + end + end + if before == nil then + before = after + end + if after == nil then + after = before + if not rail.ended then + rail.ended = true + authorEvent("railEnd", { self = instance }) + end + end + -- A stop holds the rail the moment it is reached, once. + if before.stop and not rail.passed[before] and (rail.t >= before.at) then + rail.passed[before] = true + rail.waiting = before.stop + rail.t = before.at + authorEvent("stopped", { self = instance, name = before.stop }) + end + local k = 0 + + if (after ~= before) and (after.at > before.at) then + k = (rail.t - before.at) / (after.at - before.at) + end + local x = before.x + (after.x - before.x) * k + local y = before.y + (after.y - before.y) * k + local z = before.z + (after.z - before.z) * k + + if shaking then + if authorTime() >= shaking.till then + shaking = nil else - entity.player = entity.player or (b.kind == "platformer") - kind.attach(entity, b) + x = x + (math.random() - 0.5) * shaking.amount + y = y + (math.random() - 0.5) * shaking.amount + end + end + nodeSetPosition(instance.node, x, y, z) + local look = before.look + + if type(look) == "string" then + local lx, ly, lz = authorTargetPosition(look) + + if lx then + nodeLookAt(instance.node, lx, ly, lz) + end + elseif type(look) == "table" then + local lx = look.x or look[1] or 0 + local ly = look.y or look[2] or 0 + local lz = look.z or look[3] or 0 + + if (after ~= before) and (type(after.look) == "table") then + lx = lx + ((after.look.x or after.look[1] or 0) - lx) * k + ly = ly + ((after.look.y or after.look[2] or 0) - ly) * k + lz = lz + ((after.look.z or after.look[3] or 0) - lz) * k + end + nodeLookAt(instance.node, lx, ly, lz) + end +end + + +-- Physics reports a contact between two bodies; the rules hear about it by kind. +function authorCollision(nodeA, nodeB, x, y, z, speed) + local a = authorInstanceOf(nodeA) + local b = authorInstanceOf(nodeB) + + if a and b then + authorEvent("collision", { self = a, other = b, x = x, y = y, z = z, speed = speed }) + authorEvent("collision", { self = b, other = a, x = x, y = y, z = z, speed = speed }) + end +end + + +function authorTrigger(trigger, other, entered) + local a = authorInstanceOf(trigger) + local b = authorInstanceOf(other) + + if a and b then + authorEvent(entered and "enter" or "leave", { self = a, other = b }) + end +end + + +-- A MIDI message from the input port: note-on with a velocity is a note, and the rules hear it. +function authorMidi(status, data1, data2) + local kind = math.floor((status or 0) / 16) + + if (kind == 9) and ((data2 or 0) > 0) then + authorEvent("midi", { pitch = data1, velocity = data2, channel = (status % 16) + 1 }) + end +end + + +function authorMouseMoved(x, y, xr, yr) + AUTHOR_MOUSE.dx = AUTHOR_MOUSE.dx + (xr or 0) + AUTHOR_MOUSE.dy = AUTHOR_MOUSE.dy + (yr or 0) +end + + +-- ===== Walking, hotspots, verbs, and the parser =============================================== +-- +-- The adventure's pieces (FORGE.md section 3). A room's walk areas become a navigation mesh so +-- walking in a painted room and in a modelled one is the same agent; a hotspot is where a verb +-- lands; the sentence line is built from the verb, the hotspot under the pointer, and the item in +-- hand; the parser turns a typed line into the same kind of event. + +AUTHOR_AGENTS = {} -- Instances by navigation agent, for onNavArrived. + + +-- Numbers from a table, or from text like "10,20, 30,40" typed in the editor. +function authorNumbers(value) + if type(value) == "table" then + return value + end + if type(value) ~= "string" then + return nil + end + local out = {} + + for number in string.gmatch(value, "-?[%d.]+") do + out[#out + 1] = tonumber(number) + end + + return (#out > 0) and out or nil +end + + +-- Ear clipping: a polygon of x,y pairs into triangles, as vertex indexes counted from 1. +local function triangulate(polygon) + local points = {} + local order = {} + local tris = {} + + for at = 1, #polygon - 1, 2 do + points[#points + 1] = { polygon[at], polygon[at + 1] } + order[#order + 1] = #points + end + local function area2(a, b, c) + return (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2]) + end + local function inside(p, a, b, c) + local s1 = area2(a, b, p) + local s2 = area2(b, c, p) + local s3 = area2(c, a, p) + + return ((s1 >= 0) and (s2 >= 0) and (s3 >= 0)) or ((s1 <= 0) and (s2 <= 0) and (s3 <= 0)) + end + local total = 0 + + for at = 1, #points do + local a = points[at] + local b = points[(at % #points) + 1] + + total = total + a[1] * b[2] - b[1] * a[2] + end + local sign = (total >= 0) and 1 or -1 + local guard = 0 + + while (#order > 3) and (guard < 1000) do + local clipped = false + + guard = guard + 1 + for at = 1, #order do + local i0 = order[((at - 2) % #order) + 1] + local i1 = order[at] + local i2 = order[(at % #order) + 1] + local a, b, c = points[i0], points[i1], points[i2] + + if area2(a, b, c) * sign > 0 then + local ear = true + + for _, other in ipairs(order) do + if (other ~= i0) and (other ~= i1) and (other ~= i2) and inside(points[other], a, b, c) then + ear = false + end + end + if ear then + tris[#tris + 1] = { i0, i1, i2 } + table.remove(order, at) + clipped = true + break + end + end + end + if not clipped then + break + end + end + if #order == 3 then + tris[#tris + 1] = { order[1], order[2], order[3] } + end + + return points, tris +end + + +-- The current room's navigation mesh, baked once: from its walk polygons in 2D, from the meshes +-- of the entities navFrom names in 3D. nil for a room with neither. +function authorRoomNav() + local room = AUTHOR_ROOM + + if room == nil then + return nil + end + if room.nav ~= nil then + return room.nav or nil + end + if room.walk and (#room.walk > 0) then + local nav = navNew(NAV_RADIUS_2D, NAV_HEIGHT_2D, 60, 0.05) + + for _, polygon in ipairs(room.walk) do + local points, tris = triangulate(authorNumbers(polygon) or {}) + local positions = {} + local indices = {} + + for _, point in ipairs(points) do + positions[#positions + 1] = point[1] * NAV_SCALE + positions[#positions + 1] = 0 + positions[#positions + 1] = point[2] * NAV_SCALE + end + for _, tri in ipairs(tris) do + -- Wound so the face looks up, whichever way the author drew the outline. + local a, b, c = points[tri[1]], points[tri[2]], points[tri[3]] + local up = (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2]) + + if up > 0 then + indices[#indices + 1] = tri[1] + indices[#indices + 1] = tri[3] + indices[#indices + 1] = tri[2] + else + indices[#indices + 1] = tri[1] + indices[#indices + 1] = tri[2] + indices[#indices + 1] = tri[3] + end + end + if #indices > 0 then + local node = nodeNew() + + nodeSetMesh(node, meshNew(positions, nil, nil, indices)) + nodeSetVisible(node, false) + navAddNode(nav, node) + end + end + navBuild(nav) + room.nav = { nav = nav, scale = NAV_SCALE, radius = NAV_RADIUS_2D, height = NAV_HEIGHT_2D } + elseif room.navFrom then + local nav = navNew(0.3, 1.0, 45, 0.4) + + for _, id in ipairs(room.navFrom) do + local floor = AUTHOR_BY_ID[id] + + if floor then + navAddNode(nav, floor.node) + end + end + navBuild(nav) + room.nav = { nav = nav, scale = 1, radius = 0.3, height = 1.0 } + else + room.nav = false + end + + return room.nav or nil +end + + +-- Sends a walker somewhere. Answers whether it set off. +function authorWalkStart(instance, x, y, z) + local nav = authorRoomNav() + + if (instance == nil) or (instance.agent == nil) or (nav == nil) then + return false + end + local tx, ty, tz + + if AUTHOR_3D then + tx, ty, tz = x, y, z or 0 + else + tx, ty, tz = x * NAV_SCALE, 0, y * NAV_SCALE + end + local sx, sy, sz = navNearest(nav.nav, tx, ty, tz) + + if sx == nil then + return false + end + if navAgentMoveTo(instance.agent, sx, sy, sz) then + instance.walking = true + instance.vars.state = "walk" + return true + end + + return false +end + + +-- Sends a walker somewhere and waits until it arrives, or gives up after a while. +function authorWalkTo(instance, x, y, z) + if authorWalkStart(instance, x, y, z) then + local till = authorTime() + WALK_TIMEOUT + + authorWait(function() return (not instance.alive) or (not instance.walking) or (authorTime() > till) end) + end +end + + +function authorDistance3D(a, b) + local ax, ay, az = authorPosition(a) + local bx, by, bz = authorPosition(b) + + return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2 + (az - bz) ^ 2) +end + + +function authorSpawnAtPointer(kind, player) + local x, y, z = authorPointerWorld(player) + + if x ~= nil then + return authorSpawn(kind, x, y, z) + end + + return nil +end + + +-- The world point under a player's pointer: the overlay point in 2D, the floor the ray hits in 3D. +function authorPointerWorld(player) + local px, py, on = authorPointer(player) + + if not on then + return nil + end + if not AUTHOR_3D then + return px, py, 0 + end + local x0, y0, z0 = sceneUnproject(px, py, 0) + local x1, y1, z1 = sceneUnproject(px, py, 10) + local node, hx, hy, hz = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH) + + if node == nil then + return nil + end + + return hx, hy, hz +end + + +function authorWalkToPointer(instance, player) + local x, y, z = authorPointerWorld(player) + + if x ~= nil then + authorWalkTo(instance, x, y, z) + end +end + + +function authorWalkToHotspot(instance, target) + if (target == nil) or (target.hotspot == nil) then + return + end + local p = target.hotspot + local x, y, z = authorPosition(target) + + authorWalkTo(instance, p.walkX or x, p.walkY or y, p.walkZ or z) +end + + +function authorFace(instance, direction, target) + if instance == nil then + return + end + if target then + local x, y, z = authorPosition(instance) + local tx, ty, tz = authorPosition(target) + + instance.vars.facing = (tx < x) and "left" or "right" + if instance.model then + nodeLookAt(instance.model, tx, y, tz) + end + elseif direction then + instance.vars.facing = direction + end +end + + +function authorNavArrived(agent) + local instance = AUTHOR_AGENTS[agent] + + if instance and instance.walking then + instance.walking = false + instance.vars.state = "idle" + authorEvent("arrived", { self = instance }) + end +end + + +-- The hotspot under a player's pointer, or nil. The topmost in 2D; what the ray meets in 3D. +function authorHoverInstance(player) + local px, py, on = authorPointer(player) + + if not on then + return nil + end + if AUTHOR_3D then + local x0, y0, z0 = sceneUnproject(px, py, 0) + local x1, y1, z1 = sceneUnproject(px, py, 10) + local node = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH) + local hit = node and AUTHOR_BY_NODE[node] or nil + + return (hit and hit.hotspot) and hit or nil + end + for at = #AUTHOR_LIVE, 1, -1 do + local instance = AUTHOR_LIVE[at] + + if instance.alive and instance.visible and instance.hotspot then + if instance.polygon then + if collidePointPolygon(px, py, instance.polygon) then + return instance + end + else + local x, y, w, h = authorBounds(instance) + + if collidePointRect(px, py, x, y, w, h) then + return instance + end + end end end - return entity + return nil end --- Starts the layers the game declared. Anything a layer needs of the engine is asked for here and --- nowhere else, which is what keeps the rest of this file free of genre. -function authorBegin(layers) +function authorHover(player) + local instance = authorHoverInstance(player) + + return instance and (instance.hotspot.name or instance.id) or nil +end + + +function authorGive(item) + AUTHOR_VARS.inventory = AUTHOR_VARS.inventory or {} + if not authorHas(item) then + AUTHOR_VARS.inventory[#AUTHOR_VARS.inventory + 1] = item + end +end + + +function authorTake(item) + for at = #(AUTHOR_VARS.inventory or {}), 1, -1 do + if AUTHOR_VARS.inventory[at] == item then + table.remove(AUTHOR_VARS.inventory, at) + end + end + if AUTHOR_VARS.item == item then + AUTHOR_VARS.item = nil + end +end + + +function authorSetVerb(verb) + AUTHOR_VARS.verb = verb + AUTHOR_VARS.item = nil +end + + +function authorNextVerb() + local verbs = AUTHOR_GAME.verbs or {} + local at = 0 + + for index, verb in ipairs(verbs) do + if verb == AUTHOR_VARS.verb then + at = index + end + end + AUTHOR_VARS.verb = verbs[(at % #verbs) + 1] + AUTHOR_VARS.item = nil +end + + +function authorUseItem(item) + AUTHOR_VARS.verb = "use" + AUTHOR_VARS.item = item +end + + +-- The sentence line, rebuilt every frame: "use key with door". +local function updateSentence() + if AUTHOR_GAME.verbs == nil then + return + end + local hover = authorHover(1) or "" + local verb = AUTHOR_VARS.verb or AUTHOR_GAME.verbs[1] or "" + local line = verb + + AUTHOR_VARS.hover = hover + if AUTHOR_VARS.item then + line = line .. " " .. AUTHOR_VARS.item .. ((hover ~= "") and " with " or "") + elseif hover ~= "" then + line = line .. " " + end + AUTHOR_VARS.sentence = line .. hover +end + + +-- A press of the pointer's button in a game with verbs: on a hotspot it is the verb event, with +-- the most specific rule winning; elsewhere it is left to the pressed rules (a walk, usually). +local function verbPress(player) + local target = authorHoverInstance(player) + + if target == nil then + return false + end + local verb = AUTHOR_VARS.verb or AUTHOR_GAME.verbs[1] + local item = AUTHOR_VARS.item + + authorEvent("verb", { self = target, verb = verb, target = target.hotspot.name or target.id, item = item, player = player }) + AUTHOR_VARS.verb = AUTHOR_GAME.verbs[1] + AUTHOR_VARS.item = nil + + return true +end + + +-- A word as the parser's words table knows it, or nil. +local function canonical(word) + for name, synonyms in pairs(parserLayer.words or {}) do + if name == word then + return name + end + for _, synonym in ipairs(synonyms) do + if synonym == word then + return name + end + end + end + + return nil +end + + +-- A typed line: verb, noun, and a second noun after a preposition, as the words table knows +-- them. A word it does not know is reported as unknown, so a rule can say so. +function authorSaid(text) + local words = {} + local ignore = {} + local unknown = nil + local known = {} + + for _, word in ipairs(parserLayer.ignore or { "the", "a", "an", "at", "to" }) do + ignore[word] = true + end + for word in string.gmatch(string.lower(text), "%a+") do + if not ignore[word] then + words[#words + 1] = word + end + end + for _, word in ipairs(words) do + local name = canonical(word) + + if name then + known[#known + 1] = name + elseif unknown == nil then + unknown = word + end + end + AUTHOR_VARS.typed = text + authorEvent("said", { verb = known[1], noun = known[2], second = known[3], unknown = unknown, text = text }) +end + + +-- ===== Dialogue =============================================================================== +-- +-- A dialogue is nodes of a line and choices; the compiler hands them over with each choice's +-- condition and actions as functions. talk runs one from its start and waits for it. + +function authorDialogues(list) + dialogues = list +end + + +function authorTalk(name) + local dialogue = dialogues[name] + + if dialogue == nil then + debugPrint("Author: no dialogue called '" .. tostring(name) .. "'") + return + end + local node = dialogue.nodes[dialogue.start] + local was = AUTHOR_CONTROLS + + AUTHOR_CONTROLS = false + while node ~= nil do + if node.text then + authorSay(((node.who and (node.who .. ": ")) or "") .. node.text, node.seconds) + end + local offered = {} + + for index, choice in ipairs(node.choices or {}) do + local key = name .. "/" .. tostring(node.name) .. "/" .. index + + if ((choice.when == nil) or choice.when()) and not (choice.once and onceSeen[key]) then + offered[#offered + 1] = { choice = choice, key = key } + end + end + if #offered == 0 then + node = node.next and dialogue.nodes[node.next] or nil + else + talking = { offered = offered, chosen = nil } + authorWait(function() return talking.chosen ~= nil end) + local picked = offered[talking.chosen] + + talking = nil + if picked.choice.once then + onceSeen[picked.key] = true + end + if picked.choice.run then + picked.choice.run() + end + node = picked.choice.next and dialogue.nodes[picked.choice.next] or nil + end + end + AUTHOR_CONTROLS = was +end + + +-- Whether anything is still happening that a player would wait for: a sequence running, a line +-- on screen. A dialogue waiting for a choice is not busy; it is waiting for the player. +function authorBusy() + if talking ~= nil then + return false + end + if sayLine ~= nil then + return true + end + + return next(sequences) ~= nil +end + + +-- The choices a dialogue is offering, or nil. +function authorChoices() + if talking == nil then + return nil + end + local texts = {} + + for index, offer in ipairs(talking.offered) do + texts[index] = offer.choice.text + end + + return texts +end + + +-- For tests and for a pointer: pick a choice by number. +function authorChoose(index) + if talking and talking.offered[index] then + talking.chosen = index + end +end + + +function authorFade(seconds, out) + local from = fadeLevel + local to = out and 1 or 0 + local till = authorTime() + seconds + local start = authorTime() + + authorWait(function() + local now = authorTime() + + fadeLevel = from + (to - from) * math.min(1, (now - start) / seconds) + return now >= till + end) + fadeLevel = to +end + + +function authorDie(text) + authorSay(text, nil) + authorFade(1, true) + authorRestart() + fadeLevel = 0 +end + + +-- ===== Saving ================================================================================= +-- +-- The whole game -- which room, where everything in every visited room is, every var, the +-- inventory -- as one string under a slot. Coroutines cannot be saved, so a save taken during a +-- sequence records the world as it stands and the sequence does not resume. + +local function sourceOf(value) + if type(value) == "table" then + local parts = {} + + for key, item in pairs(value) do + local name = (type(key) == "string") and (key:match("^[%a_][%w_]*$") and key or ("[" .. string.format("%q", key) .. "]")) or ("[" .. tostring(key) .. "]") + + parts[#parts + 1] = name .. " = " .. sourceOf(item) + end + return "{ " .. table.concat(parts, ", ") .. " }" + elseif type(value) == "string" then + return string.format("%q", value) + end + + return tostring(value) +end + + +local function snapshotRoom(list) + local entities = {} + + for _, instance in ipairs(list) do + 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 } + end + end + + return entities +end + + +function authorSaveGame(slot) + local rooms = {} + + for _, room in ipairs(AUTHOR_GAME.rooms) do + if room == AUTHOR_ROOM then + rooms[room.name] = snapshotRoom(AUTHOR_LIVE) + elseif room.kept then + rooms[room.name] = snapshotRoom(room.kept) + end + end + saveSet("forge.slot" .. tostring(slot), "return " .. sourceOf({ room = AUTHOR_ROOM.name, vars = AUTHOR_VARS, rooms = rooms, once = onceSeen })) +end + + +function authorLoadGame(slot) + local text = saveGet("forge.slot" .. tostring(slot)) + + if text == nil then + return false + end + local chunk = load(text, "save", "t", {}) + + if chunk == nil then + return false + end + local saved = chunk() + + authorRestart() + AUTHOR_VARS = saved.vars + onceSeen = saved.once or {} + for _, room in ipairs(AUTHOR_GAME.rooms) do + room.saved = saved.rooms[room.name] + if room.kept then + for _, instance in ipairs(room.kept) do + nodeDelete(instance.node) + end + room.kept = nil + end + end + for at = #AUTHOR_LIVE, 1, -1 do + authorDestroy(AUTHOR_LIVE[at]) + end + AUTHOR_ROOM = nil + authorGoTo(saved.room) + + return true +end + + +-- ===== Sequences ============================================================================== +-- +-- An action that takes time yields from the rule's coroutine until it is done; authorWait is what +-- it yields through. Outside a coroutine (a rule the compiler saw no waiting action in) the wait +-- is skipped and the action's effect stands, so nothing breaks when a rule is edited by hand. + +function authorWait(predicate) + if not coroutine.isyieldable() then + return + end + while not predicate() do + coroutine.yield() + end +end + + +function authorWaitSeconds(seconds) + local till = authorTime() + seconds + + authorWait(function() return authorTime() >= till end) +end + + +function authorSay(text, seconds) + text = tostring(text or "") + seconds = seconds or (SAY_SECONDS_MIN + #text * SAY_PER_CHAR) + sayLine = { text = text, till = authorTime() + seconds } + if hud then + guiSetValue(hud.gui, hud.document, "say", text) + end + authorWait(function() return authorTime() >= sayLine.till end) +end + + +-- ===== Rules ================================================================================== +-- +-- The compiler hands over the rules once; each is { on, filter, each, waits, controls, run }. run +-- takes (self, other, event) and does the conditions and actions. A rule with waits runs as a +-- coroutine kept until it finishes; the same rule on the same instance does not start again while +-- one is running unless the rule says interrupt. + +function authorRules(list) + rules = {} + pairsToTest = {} + for _, rule in ipairs(list) do + rules[rule.on] = rules[rule.on] or {} + rules[rule.on][#rules[rule.on] + 1] = rule + if rule.on == "collision" then + pairsToTest[#pairsToTest + 1] = { a = rule.filter.a, b = rule.filter.b } + end + end + -- The first room was built by authorBegin, before this; what it raised -- roomStart, a spawn + -- for every entity placed in it -- is delivered now that there are rules to hear it. + rulesReady = true + for _, held in ipairs(pending) do + authorEvent(held.name, held.event) + end + pending = {} +end + + +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. +local function dispatch(rule, event) + if rule.each and (event.self == nil) then + local any = false + + for _, instance in ipairs(authorEach(rule.each)) do + if runRule(rule, instance, event.other, event) then + any = true + end + end + return any + end + + return runRule(rule, event.self, event.other, event) +end + + +-- Runs a rule. Answers whether its conditions held -- a sequence that yielded has passed them, +-- since the conditions come before the first action. +function runRule(rule, self, other, event) + if not rule.waits then + return rule.run(self, other, event) == true + end + local key = tostring(rule) .. "/" .. (self and self.id or "") + + if sequences[key] and not rule.interrupt then + return false + end + local co = coroutine.create(function() + local held + + if rule.controls == false then + AUTHOR_CONTROLS = false + end + held = rule.run(self, other, event) + if rule.controls == false then + AUTHOR_CONTROLS = true + end + return held + end) + + sequences[key] = co + local ok, held = coroutine.resume(co) + + if not ok then + debugPrint("Author: " .. tostring(held)) + sequences[key] = nil + return false + end + if coroutine.status(co) == "dead" then + sequences[key] = nil + return held == true + end + + return true +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 + return false + end + elseif name == "a" then + if (event.self == nil) or (event.self.kind ~= want) then + return false + end + elseif name == "b" then + if (event.other == nil) or (event.other.kind ~= 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 + return false + end + if rule.room and ((AUTHOR_ROOM == nil) or (AUTHOR_ROOM.name ~= rule.room)) then + return false + end + + return true +end + + +-- Something happened. event carries self, other, and whatever the event has to say. An +-- exclusive event -- a verb, a typed line -- runs only the most specific rules that match: "use +-- key on door" beats "use anything on door" beats "use". +function authorEvent(name, event) + local list = rules[name] + local entry = AUTHOR.events[name] + + if gameOver and (name ~= "gameOver") and (name ~= "pressed") then + return + end + if event.self then + authorBehaviourEvent(event.self, name, event) + end + if not rulesReady then + pending[#pending + 1] = { name = name, event = event } + return + end + if list == nil then + return + end + if entry and entry.exclusive then + -- Tiers by how many of the event's parameters a rule names, a rule with conditions + -- ranking above one without among the same parameters; the most specific tier whose + -- conditions hold is the one that acts, and the rest are left alone. + local tiers = {} + + for _, rule in ipairs(list) do + if matches(rule, event) then + local score = rule.guarded and 1 or 0 + + for _ in pairs(rule.filter) do + score = score + 2 + end + tiers[score] = tiers[score] or {} + tiers[score][#tiers[score] + 1] = rule + end + end + for score = 7, 0, -1 do + local ran = false + + for _, rule in ipairs(tiers[score] or {}) do + if dispatch(rule, event) then + ran = true + end + end + if ran then + return + end + end + return + end + for _, rule in ipairs(list) do + if matches(rule, event) then + dispatch(rule, event) + end + end +end + + +-- The behaviours hear about their instance's events too: a sound plays its clip on hit. +function authorBehaviourEvent(instance, name, event) + for _, b in ipairs(instance.behaviours) do + local behaviour = AUTHOR.behaviours[b.kind] + + if behaviour and behaviour.on then + behaviour.on(instance, name, event or {}) + end + end +end + + +local function runFrameRules() + local list = rules.frame + + if (list == nil) or gameOver then + return + end + for _, rule in ipairs(list) do + if (rule.room == nil) or (AUTHOR_ROOM and AUTHOR_ROOM.name == rule.room) then + if rule.each then + for _, instance in ipairs(authorEach(rule.each)) do + runRule(rule, instance, nil, {}) + end + else + runRule(rule, nil, nil, {}) + end + end + end +end + + +local function resumeSequences() + local running = {} + + -- A snapshot, because a resumed rule may start another sequence, and adding to a table while + -- pairs walks it is not allowed. + for key, co in pairs(sequences) do + running[#running + 1] = { key = key, co = co } + end + for _, entry in ipairs(running) do + if coroutine.status(entry.co) == "dead" then + sequences[entry.key] = nil + elseif sequences[entry.key] == entry.co then + local ok, err = coroutine.resume(entry.co) + + if not ok then + debugPrint("Author: " .. tostring(err)) + sequences[entry.key] = nil + elseif coroutine.status(entry.co) == "dead" then + sequences[entry.key] = nil + end + end + end +end + + +-- ===== Rooms ================================================================================== + +local function roomNamed(name) + for _, room in ipairs(AUTHOR_GAME.rooms) do + if room.name == name then + return room + end + end + + return nil +end + + +local function buildRoom(room) + local from = room.saved or room.entities or {} + + room.saved = nil + for _, entry in ipairs(from) do + make(entry.kind, entry.x, entry.y, entry.z, entry) + end + authorRoomNav() +end + + +-- Leaves the current room, keeping its instances if it wants to be remembered. Kept instances +-- keep their nodes; they are only taken out of the live list and drawn no more. +local function leaveRoom() + if AUTHOR_ROOM == nil then + return + end + authorEvent("roomEnd", { room = AUTHOR_ROOM.name }) + if AUTHOR_ROOM.reset then + for at = #AUTHOR_LIVE, 1, -1 do + authorDestroy(AUTHOR_LIVE[at]) + end + AUTHOR_ROOM.kept = nil + else + AUTHOR_ROOM.kept = AUTHOR_LIVE + for _, instance in ipairs(AUTHOR_LIVE) do + nodeSetVisible(instance.node, false) + end + end + -- Agents belong to the room's mesh; a walker makes a new one where it arrives. + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.agent then + navAgentDelete(instance.agent) + AUTHOR_AGENTS[instance.agent] = nil + instance.agent = nil + if instance.navNode and (instance.navNode ~= instance.node) then + nodeDelete(instance.navNode) + instance.navNode = nil + end + end + end + AUTHOR_LIVE = {} + AUTHOR_BY_ID = {} + timers = {} + -- Sequences stay: the cut-scene that walked through the door is the one changing rooms, and + -- it has a fade-in and a score still to do on the other side. +end + + +function authorGoTo(name, instance, x, y, z, at) + local room = roomNamed(name) + + if room == nil then + debugPrint("Author: no room called '" .. tostring(name) .. "'") + return + end + local carried = instance and instance.alive and instance or nil + + if carried then + -- The one that travels comes out of the room it leaves before it is stashed. + for at = #AUTHOR_LIVE, 1, -1 do + if AUTHOR_LIVE[at] == carried then + table.remove(AUTHOR_LIVE, at) + end + end + end + leaveRoom() + AUTHOR_ROOM = room + if room.kept then + AUTHOR_LIVE = room.kept + room.kept = nil + for _, kept in ipairs(AUTHOR_LIVE) do + AUTHOR_BY_ID[kept.id] = kept + nodeSetVisible(kept.node, true) + if kept.camera then + cameraNode = kept.node + cameraSet(kept.node) + end + end + else + buildRoom(room) + end + if carried then + -- An instance of the same id already in the room gives way to the one arriving. + local standing = AUTHOR_BY_ID[carried.id] + + if standing and (standing ~= carried) then + authorDestroy(standing) + end + AUTHOR_LIVE[#AUTHOR_LIVE + 1] = carried + AUTHOR_BY_ID[carried.id] = carried + AUTHOR_BY_NODE[carried.node] = carried + for _, node in ipairs(carried.nodes) do + AUTHOR_BY_NODE[node] = carried + end + if at and AUTHOR_BY_ID[at] then + local ax, ay, az = authorPosition(AUTHOR_BY_ID[at]) + + authorMoveTo(carried, ax, ay, az) + elseif x ~= nil then + authorMoveTo(carried, x, y, z) + end + end + for _, spawned in ipairs(AUTHOR_LIVE) do + if spawned ~= carried and not spawned.begun then + spawned.begun = true + authorEvent("spawn", { self = spawned }) + end + end + authorEvent("roomStart", { room = name }) +end + + +function authorRestart() + for _, room in ipairs(AUTHOR_GAME.rooms) do + if room.kept then + for _, instance in ipairs(room.kept) do + nodeDelete(instance.node) + end + room.kept = nil + end + end + for at = #AUTHOR_LIVE, 1, -1 do + authorDestroy(AUTHOR_LIVE[at]) + end + AUTHOR_ROOM = nil + cameraNode = nil + sequences = {} + AUTHOR_VARS = copyOf(AUTHOR_GAME.vars or {}) + AUTHOR_VARS.score = AUTHOR_VARS.score or 0 + if AUTHOR_GAME.verbs then + AUTHOR_VARS.verb = AUTHOR_GAME.verbs[1] + AUTHOR_VARS.inventory = AUTHOR_VARS.inventory or {} + end + AUTHOR_TYPING = "" + talking = nil + fadeLevel = 0 + results = nil + AUTHOR_VARS.best = saveGet(BEST_KEY, 0) + onceSeen = {} + gameOver = false + sayLine = nil + AUTHOR_STARTED = singeGetTicks() + lastTime = 0 + lastFrame = -1 + authorGoTo(AUTHOR_GAME.rooms[1].name) +end + + +-- ===== Pointers and guns ====================================================================== + +-- A player's pointer in overlay coordinates, and whether it is on the picture. A test can set it. +function authorPointer(player) + local device = (player or 1) - 1 + + if pointers[player or 1] then + return pointers[player or 1].x, pointers[player or 1].y, pointers[player or 1].on + end + if device >= mouseHowMany() then + device = 0 + end + local x, y = mouseGetPosition(device) + + return x, y, (x >= 0) and (y >= 0) and (x < overlayGetWidth()) and (y < overlayGetHeight()) +end + + +-- For tests: where a player's pointer is, and whether it counts as on the picture. +function authorSetPointer(player, x, y, on) + if x == nil then + pointers[player] = nil + else + pointers[player] = { x = x, y = y, on = (on ~= false) } + end +end + + +function authorPointerIn(instance, player) + if instance == nil then + return false + end + local px, py, on = authorPointer(player) + local x, y, w, h = authorHitShape(instance) + + return on and (x ~= nil) and collidePointRect(px, py, x, y, w, h) +end + + +function authorPointerOffscreen(player) + local _, _, on = authorPointer(player) + + return not on +end + + +-- What a gun can hit an instance in: its track's box at this frame when it has a hitbox +-- behaviour, its look's box otherwise. nil when the track has no box now. +function authorHitShape(instance) + if instance.hitTrack then + local box = authorTrackBox(instance.hitTrack) + + if box == nil then + return nil + end + + return box.x, box.y, box.w, box.h + end + + return authorBounds(instance) +end + + +-- A shot from a player's gun. The topmost instance under the pointer is hit; nothing is a miss. +local function fire(gun) + local player = gun.vars.player + local px, py, on = authorPointer(player) + + if gun.gun.aim == "centre" then + px, py, on = overlayGetWidth() / 2, overlayGetHeight() / 2, true + end + + if gun.vars.ammo <= 0 then + authorBehaviourEvent(gun, "empty") + if (gun.gun.reload == "offscreen") and not on then + authorReload(gun) + end + return + end + if (gun.gun.reload == "offscreen") and not on then + authorReload(gun) + return + end + gun.vars.ammo = gun.vars.ammo - 1 + authorBehaviourEvent(gun, "fire") + if on and AUTHOR_3D then + -- A ray through the pointer, from the near plane out: what it hits is a body, and the + -- body's node says which instance -- and which zone of it, when it has zones. + local x0, y0, z0 = sceneUnproject(px, py, 0) + local x1, y1, z1 = sceneUnproject(px, py, 10) + local node, hx, hy, hz = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH) + local target = node and authorInstanceOf(node) or nil + local casts = 0 + + -- What the ray meets first may be something a shot passes through: the hero's own body + -- in first person, a trigger, a wall of glass. The cast goes on from just past it, a few + -- times, before the shot counts as a miss. + while node and not (target and target.isTarget) and (casts < 4) do + local dx, dy, dz = x1 - x0, y1 - y0, z1 - z0 + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + + casts = casts + 1 + x0, y0, z0 = hx + dx / len * 0.05, hy + dy / len * 0.05, hz + dz / len * 0.05 + x1, y1, z1 = x0 + dx, y0 + dy, z0 + dz + node, hx, hy, hz = physicsRaycast(x0, y0, z0, dx, dy, dz, RAY_REACH) + target = node and authorInstanceOf(node) or nil + end + if target and target.alive and target.isTarget then + -- The body box is what the ray meets first; a zone inside it -- the head -- is found by + -- how close the ray passes to the zone's centre. + local part = "body" + local dx, dy, dz = x1 - x0, y1 - y0, z1 - z0 + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + + dx, dy, dz = dx / len, dy / len, dz / len + for _, zone in ipairs(target.zones or {}) do + local zx, zy, zz = nodeGetWorldPosition(zone.node) + local t = (zx - x0) * dx + (zy - y0) * dy + (zz - z0) * dz + local cx, cy, cz = x0 + dx * t - zx, y0 + dy * t - zy, z0 + dz * t - zz + + if (t > 0) and (math.sqrt(cx * cx + cy * cy + cz * cz) <= zone.reach) then + part = zone.name + end + end + authorEvent("hit", { self = target, other = gun, player = player, x = hx, y = hy, z = hz, part = part }) + return + end + elseif on then + for at = #AUTHOR_LIVE, 1, -1 do + local target = AUTHOR_LIVE[at] + + if target.alive and target.visible and target.isTarget then + local x, y, w, h = authorHitShape(target) + + if (x ~= nil) and collidePointRect(px, py, x, y, w, h) then + authorEvent("hit", { self = target, other = gun, player = player, x = px, y = py }) + return + end + end + end + end + authorEvent("miss", { self = gun, player = player, offscreen = not on, x = px, y = py }) +end + + +-- ===== Tracks ================================================================================= +-- +-- A track is a list of keys by frame or time. A box track answers the box at the current key +-- position, interpolated between keys, nil outside them. + +function authorTrackNamed(name) + for _, track in ipairs(AUTHOR_ROOM and AUTHOR_ROOM.tracks or {}) do + if track.name == name then + return track + end + end + for _, track in ipairs(AUTHOR_GAME.tracks or {}) do + if track.name == name then + return track + end + end + + return nil +end + + +local function trackNow(track) + if track.key == "frame" then + return discGetFrame() + end + + return authorTime() +end + + +-- The interpolated box of a track at a position along it. +function authorTrackBoxAt(track, at) + local keys = track.boxes or {} + local before, after + + for _, key in ipairs(keys) do + if key.at <= at and ((before == nil) or (key.at > before.at)) then + before = key + end + if key.at >= at and ((after == nil) or (key.at < after.at)) then + after = key + end + end + if (before == nil) or (after == nil) then + return nil + end + if (before == after) or (after.at == before.at) then + return { x = before.x, y = before.y, w = before.w, h = before.h } + end + local t = (at - before.at) / (after.at - before.at) + + return { + x = before.x + (after.x - before.x) * t, + y = before.y + (after.y - before.y) * t, + w = before.w + (after.w - before.w) * t, + h = before.h + (after.h - before.h) * t + } +end + + +function authorTrackBox(name) + local track = authorTrackNamed(name) + + if track == nil then + return nil + end + + return authorTrackBoxAt(track, trackNow(track)) +end + + +-- ===== Timers and the disc ==================================================================== + +local function updateTimers() + local now = authorTime() + + for at = #timers, 1, -1 do + local timer = timers[at] + + -- A timer's rule may destroy an instance and take its timers out from under this loop. + if timer == nil then + -- Nothing left at this index. + elseif timer.instance.alive and (now >= timer.at) then + if timer.every then + timer.at = now + timer.every + else + table.remove(timers, at) + end + authorEvent("timer", { self = timer.instance, name = timer.name }) + elseif not timer.instance.alive then + table.remove(timers, at) + end + end +end + + +local function updateDisc() + if (discGetState == nil) or (AUTHOR_GAME.disc == false) then + return + end + local frame = discGetFrame() + local list = rules.frameReached + + if list and (lastFrame >= 0) and (frame > lastFrame) then + for _, rule in ipairs(list) do + local want = rule.filter.frame + + if want and (want > lastFrame) and (want <= frame) then + runRule(rule, nil, nil, { frame = frame }) + end + end + end + lastFrame = frame +end + + +-- ===== Collisions ============================================================================= +-- +-- The rules say which kinds they care about; only those pairs are tested, and a pair that goes on +-- touching is reported once. + +local touchingNow = {} + +local function checkCollisions() + local seen = {} + + for _, pair in ipairs(pairsToTest) do + for _, a in ipairs(authorEach(pair.a)) do + for _, b in ipairs(authorEach(pair.b)) do + if (a ~= b) and a.alive and b.alive and authorTouching(a, b) then + local key = a.id .. "|" .. b.id + + seen[key] = true + if not touchingNow[key] then + authorEvent("collision", { self = a, other = b }) + end + end + end + end + end + touchingNow = seen +end + + +-- ===== HUD ==================================================================================== + +local function updateHud() + if hud == nil then + return + end + for id, var in pairs(hud.bind) do + local value = AUTHOR_VARS[var] + + if type(value) == "table" then + value = table.concat(value, ", ") + else + value = tostring(value or "") + end + + if hud.shown[id] ~= value then + hud.shown[id] = value + guiSetValue(hud.gui, hud.document, id, value) + end + end + if sayLine and (authorTime() >= sayLine.till) then + sayLine = nil + guiSetValue(hud.gui, hud.document, "say", "") + end +end + + +-- ===== The frame ============================================================================== + +-- Builds the game from its description: layers, vars, and the first room. The compiled game +-- calls this once, then authorRules with what it compiled, then authorFrame every frame. +function authorBegin(game) -- A font, because the text look draws with fontPrint and fontPrint ends the game when none is - -- selected. Every test had a scene select one first; a released game has nobody to do that, - -- and died on its first text entity. A game that wants its own calls fontSelect afterwards. + -- selected. A game that wants its own calls fontSelect afterwards. if AUTHOR_FONT == nil then AUTHOR_FONT = fontLoad("Singe/FreeSansBold.ttf", AUTHOR_FONT_POINTS) fontSelect(AUTHOR_FONT) fontQuality(FONT_QUALITY_BLENDED) end - - AUTHOR_STARTED = singeGetTicks() - lastTime = 0 - for _, layer in ipairs(layers) do + MOUSE_LEFT = SWITCH_BUTTON3 + AUTHOR_GAME = game + -- The overlay the game was drawn for. Without it the engine's default stands, which over a + -- disc is the disc's own size. + if game.size then + overlaySetResolution(game.size.w, game.size.h) + end + game.kinds = game.kinds or {} + game.rooms = game.rooms or { { name = "main", entities = {} } } + game.disc = false + for _, layer in ipairs(game.layers or {}) do local kind = AUTHOR.layers[layer.kind] if kind == nil then debugPrint("Author: no layer called '" .. tostring(layer.kind) .. "'") else - AUTHOR_LAYERS[#AUTHOR_LAYERS + 1] = layer + if layer.kind == "disc" then + game.disc = true + end kind.begin(layer) end end + if (game.players or 1) > 1 then + mouseSetMode(MOUSE_MANY) + end + authorRestart() end --- One frame: the behaviours step, then the rules the compiler wrote, then everything is drawn in --- the order it was declared. The generated game calls this from onOverlayUpdate and does no more. -function authorFrame(rules) +-- One frame: timers, behaviours, sequences, the frame rules, collisions, the HUD, and everything +-- drawn in the order it was made. The generated game calls this from onOverlayUpdate. +function authorFrame() local now = authorTime() - local dt = now - lastTime + local dt = math.min(now - lastTime, FRAME_CAP) lastTime = now - for _, entity in ipairs(AUTHOR_ORDER) do - for _, b in ipairs(entity.behaviours) do - local kind = AUTHOR.behaviours[b.kind] + updateTimers() + updateDisc() + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.alive then + for _, b in ipairs(instance.behaviours) do + local behaviour = AUTHOR.behaviours[b.kind] - if kind ~= nil and kind.step ~= nil then - kind.step(entity, dt) + if behaviour and behaviour.step then + behaviour.step(instance, dt) + end end end end - - if rules ~= nil then - rules() + resumeSequences() + runFrameRules() + checkCollisions() + updateSentence() + updateHud() + updateBezel() + if masterReady and (masterPump ~= nil) then + masterPump() end + pressedNow = {} + AUTHOR_MOUSE.dx = 0 + AUTHOR_MOUSE.dy = 0 overlayClear() - for _, entity in ipairs(AUTHOR_ORDER) do - if entity.visible then - AUTHOR.looks[entity.look.kind].draw(entity) + if AUTHOR_ROOM and AUTHOR_ROOM.depthSort then + -- Further up the picture is further away, so what is lower is drawn last, over it. + local order = {} + + for at, instance in ipairs(AUTHOR_LIVE) do + order[at] = instance + instance.order = at end + table.sort(order, function(a, b) + local ay, by = authorY(a), authorY(b) + + if ay == by then + return a.order < b.order + end + return ay < by + end) + for _, instance in ipairs(order) do + if instance.alive and instance.visible then + AUTHOR.looks[instance.look.kind].draw(instance) + end + end + else + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.alive and instance.visible then + AUTHOR.looks[instance.look.kind].draw(instance) + end + end + end + -- Particles in a 2D room are drawn on request, unlike the scene's. + if not AUTHOR_3D then + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.alive and instance.emitter then + emitterDraw(instance.emitter) + end + end + end + if talking then + -- The choices, numbered, low on the picture (or in the hud's choices element). + local lines = {} + + for index, offer in ipairs(talking.offered) do + lines[#lines + 1] = index .. ". " .. offer.choice.text + end + if hud then + guiSetValue(hud.gui, hud.document, "choices", table.concat(lines, "\n")) + else + colorForeground(255, 230, 120, 255) + for index, line in ipairs(lines) do + fontPrint(40, overlayGetHeight() - 40 - (#lines - index + 1) * 22, line) + end + end + elseif hud and hud.choicesShown then + guiSetValue(hud.gui, hud.document, "choices", "") + end + if hud then + hud.choicesShown = (talking ~= nil) + end + if parserLayer then + colorForeground(235, 235, 245, 255) + fontPrint(12, overlayGetHeight() - 30, (parserLayer.prompt or ">") .. AUTHOR_TYPING .. "_") + end + if results then + local middle = overlayGetHeight() / 2 + + colorForeground(255, 230, 120, 255) + fontPrint(overlayGetWidth() / 2 - 60, middle - 30, "GAME OVER") + colorForeground(235, 235, 245, 255) + fontPrint(overlayGetWidth() / 2 - 60, middle, "score " .. results.score) + fontPrint(overlayGetWidth() / 2 - 60, middle + 24, "best " .. results.best) + end + if fadeLevel > 0 then + colorForeground(0, 0, 0, math.floor(255 * fadeLevel)) + for row = 0, overlayGetHeight() - 1 do + overlayLine(0, row, overlayGetWidth(), row) + end + end + if flashLine then + if now >= flashLine.till then + flashLine = nil + else + local alpha = math.floor(255 * (flashLine.till - now) / flashLine.seconds) + + colorForeground(flashLine.r, flashLine.g, flashLine.b, alpha) + for row = 0, overlayGetHeight() - 1, 2 do + overlayLine(0, row, overlayGetWidth(), row) + end + end + end + if sayLine and (hud == nil) then + if now >= sayLine.till then + sayLine = nil + else + colorForeground(255, 255, 255, 255) + fontPrint(overlayGetWidth() / 2 - #sayLine.text * 4, overlayGetHeight() - 60, sayLine.text) + end + end + if hud then + guiDraw(hud.gui) end end -- ===== Input ================================================================================== -- --- Held state, because rules ask "is this key down" rather than "was it just pressed". The --- generated game points the engine's callbacks straight at these. +-- Held state, because rules ask "is this key down" rather than "was it just pressed", and edges, +-- because some do. The generated game points the engine's callbacks straight at these. AUTHOR_KEYS = {} AUTHOR_SWITCHES = {} @@ -549,19 +4521,78 @@ AUTHOR_SWITCHES = {} function authorKeyDown(keysym, scancode) AUTHOR_KEYS[scancode] = true + pressedNow[scancode] = true + -- A dialogue's choices take the number keys first. + if talking then + for index, name in ipairs(CHOICE_KEYS) do + if scancode == SCANCODE[name].value then + authorChoose(index) + return + end + end + end + -- The parser takes the typing keys. + if parserLayer and AUTHOR_CONTROLS then + if scancode == SCANCODE.RETURN.value then + local line = AUTHOR_TYPING + + AUTHOR_TYPING = "" + if line ~= "" then + authorSaid(line) + end + return + elseif scancode == SCANCODE.BACKSPACE.value then + AUTHOR_TYPING = string.sub(AUTHOR_TYPING, 1, -2) + return + elseif (keysym >= 32) and (keysym < 127) then + AUTHOR_TYPING = AUTHOR_TYPING .. string.char(keysym) + return + end + end + if AUTHOR_CONTROLS then + local px, py = authorPointer(1) + + authorEvent("pressed", { key = scancode, x = px, y = py }) + end end function authorKeyUp(keysym, scancode) AUTHOR_KEYS[scancode] = nil + authorEvent("released", { key = scancode }) end function authorSwitchDown(what) AUTHOR_SWITCHES[what] = true + pressedNow[what] = true + if not AUTHOR_CONTROLS then + return + end + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.alive and instance.gun and (what == (instance.trigger or MOUSE_LEFT)) then + fire(instance) + end + end + if talking and (what == MOUSE_LEFT) then + -- A click picks the choice under the pointer, by its line. + local _, py = authorPointer(1) + local count = #talking.offered + local index = count - math.floor((overlayGetHeight() - 40 - py) / 22) + + authorChoose(math.max(1, math.min(count, index))) + return + end + if AUTHOR_GAME.verbs and (what == MOUSE_LEFT) and verbPress(1) then + return + end + local px, py = authorPointer(1) + + authorEvent("pressed", { switch = what, x = px, y = py }) end function authorSwitchUp(what) AUTHOR_SWITCHES[what] = nil + authorEvent("released", { switch = what }) end diff --git a/assets/Forge/AuthorCompile.singe b/assets/Forge/AuthorCompile.singe index 1801d658a..32d01bd3f 100644 --- a/assets/Forge/AuthorCompile.singe +++ b/assets/Forge/AuthorCompile.singe @@ -23,19 +23,19 @@ --- Turns a game description into an ordinary Singe game (PLAN section 58). +-- The compiler: a game description to a Singe game (FORGE.md). -- --- The output is Lua a person can read, keep, and edit by hand; the description is kept beside it --- so the editor can open it again. Compiling rather than interpreting costs nothing at build time --- and saves walking a rule table every frame on a Pi or a handheld -- and it means there is no wall --- between what the tools make and what someone writes themselves. +-- A description is data -- kinds, 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 +-- direction. -- --- This is written in Lua rather than in util/ as a Python script because the editor is itself a --- Singe game: it has to be able to compile what it is editing without leaving the engine. It --- lives with Forge rather than in Singe/ because only building needs it -- a finished game is --- already Lua. Author.singe, the runtime that game loads, travels with the game: a copy is --- written beside it when it is built, so a game handed to someone who has never installed Forge --- still runs. The compiler loads it too, for the vocabulary it emits against. +-- Written in Lua rather than as a util/ script because the editor is itself a Singe game and has to +-- compile what it is editing without leaving the engine. It lives with Forge rather than in Singe/ +-- because only building needs it; Author.singe, the runtime a built game loads, travels with the +-- game -- a copy is written beside it when it is built -- so a game handed to someone who has never +-- installed Forge still runs. -- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy. AUTHOR_RUNTIME = "Forge/Author.singe" @@ -46,209 +46,477 @@ dofile(AUTHOR_RUNTIME) local INDENT = "\t" --- A Lua literal for a value that came out of a description. Numbers and booleans go through as --- they are; everything else is quoted, because an author typing a name must not be able to end --- the string and start writing code. AUTHOR.actions.lua is the one deliberate way in. -local function literal(value) - if type(value) == "number" or type(value) == "boolean" then +-- ===== Expressions ============================================================================ +-- +-- A small, safe language where a number goes: numbers, strings, arithmetic, comparison, and, or, +-- not, self.var, other.var, event.field, an entity's field by name, the game's vars by name, and a +-- few functions. Anything else is a compile error naming the expression, and the lua action is +-- the way past it. + +local FUNCTIONS = { + count = "authorCount", + random = "authorRandom", + distance = "authorDistance", + has = "authorHas", + abs = "math.abs", + min = "math.min", + max = "math.max", + floor = "math.floor", + time = "authorTime", + frame = "discGetFrame" +} + +local NAMES = { + self = "self", + other = "other", + event = "event", + time = "authorTime()", + frame = "discGetFrame()" +} + +local OPERATORS = { + ["=="] = "==", ["~="] = "~=", ["!="] = "~=", ["<="] = "<=", [">="] = ">=", ["<"] = "<", [">"] = ">", + ["+"] = "+", ["-"] = "-", ["*"] = "*", ["/"] = "/", ["%"] = "%", ["^"] = "^", [".."] = ".." +} + + +local function tokenize(text) + local tokens = {} + local at = 1 + + while at <= #text do + local c = text:sub(at, at) + + if c:match("%s") then + at = at + 1 + elseif c:match("[%d%.]") and text:sub(at):match("^%d") or text:sub(at):match("^%.%d") then + local number = text:sub(at):match("^%d*%.?%d+[eE][-+]?%d+") or text:sub(at):match("^%d*%.?%d+") or text:sub(at):match("^%d+") + + tokens[#tokens + 1] = { kind = "number", value = number } + at = at + #number + elseif c:match("[%a_]") then + local name = text:sub(at):match("^[%a_][%w_]*") + + tokens[#tokens + 1] = { kind = "name", value = name } + at = at + #name + elseif (c == '"') or (c == "'") then + -- A backslash escapes the next character, so a line can hold its own quote marks. + local value = {} + local scan = at + 1 + + while (scan <= #text) and (text:sub(scan, scan) ~= c) do + local ch = text:sub(scan, scan) + + if ch == "\\" then + local escaped = text:sub(scan + 1, scan + 1) + + value[#value + 1] = (escaped == "n") and "\n" or escaped + scan = scan + 2 + else + value[#value + 1] = ch + scan = scan + 1 + end + end + if scan > #text then + error("unterminated string in: " .. text) + end + tokens[#tokens + 1] = { kind = "string", value = table.concat(value) } + at = scan + 1 + else + local two = text:sub(at, at + 1) + + if OPERATORS[two] then + tokens[#tokens + 1] = { kind = "op", value = two } + at = at + 2 + elseif OPERATORS[c] or c == "(" or c == ")" or c == "," or c == "." then + tokens[#tokens + 1] = { kind = "op", value = c } + at = at + 1 + else + error("cannot read '" .. c .. "' in: " .. text) + end + end + end + + return tokens +end + + +-- Recursive descent, lowest precedence first. Every level returns Lua source. +local function parser(text) + local tokens = tokenize(text) + local at = 1 + local p = {} + + local function peek(kind, value) + local token = tokens[at] + + return token and (token.kind == kind) and ((value == nil) or (token.value == value)) + end + + local function take() + at = at + 1 + return tokens[at - 1] + end + + local function expect(kind, value) + if not peek(kind, value) then + error("expected " .. (value or kind) .. " in: " .. text) + end + return take() + end + + function p.primary() + local token = take() + + if token == nil then + error("expression ends early: " .. text) + end + if token.kind == "number" then + return token.value + elseif token.kind == "string" then + return string.format("%q", token.value) + elseif token.kind == "op" and token.value == "(" then + local inner = p.expression() + + expect("op", ")") + return "(" .. inner .. ")" + elseif token.kind == "op" and token.value == "-" then + return "(-" .. p.unary() .. ")" + elseif token.kind == "name" then + local name = token.value + + if name == "true" or name == "false" or name == "nil" then + return name + end + if name == "not" then + return "(not " .. p.unary() .. ")" + end + if peek("op", "(") then + local target = FUNCTIONS[name] + local args = {} + + if target == nil then + error("no function called " .. name .. " in: " .. text) + end + take() + if not peek("op", ")") then + args[#args + 1] = p.expression() + while peek("op", ",") do + take() + args[#args + 1] = p.expression() + end + end + expect("op", ")") + return target .. "(" .. table.concat(args, ", ") .. ")" + end + if peek("op", ".") then + take() + local field = expect("name").value + + if name == "event" then + return "event[" .. string.format("%q", field) .. "]" + elseif NAMES[name] and (name == "self" or name == "other") then + return "authorField(" .. name .. ", " .. string.format("%q", field) .. ")" + end + return "authorField(authorEntity(" .. string.format("%q", name) .. "), " .. string.format("%q", field) .. ")" + end + if NAMES[name] then + return NAMES[name] + end + return "AUTHOR_VARS[" .. string.format("%q", name) .. "]" + end + error("unexpected '" .. tostring(token.value) .. "' in: " .. text) + end + + function p.unary() + return p.primary() + end + + function p.power() + local left = p.unary() + + while peek("op", "^") do + take() + left = "(" .. left .. " ^ " .. p.unary() .. ")" + end + return left + end + + function p.product() + local left = p.power() + + while peek("op", "*") or peek("op", "/") or peek("op", "%") do + local op = take().value + + left = "(" .. left .. " " .. op .. " " .. p.power() .. ")" + end + return left + end + + function p.sum() + local left = p.product() + + while peek("op", "+") or peek("op", "-") or peek("op", "..") do + local op = take().value + + left = "(" .. left .. " " .. op .. " " .. p.product() .. ")" + end + return left + end + + function p.comparison() + local left = p.sum() + + while peek("op") and OPERATORS[tokens[at].value] and tokens[at].value:match("[=<>~!]") do + local op = OPERATORS[take().value] + + left = "(" .. left .. " " .. op .. " " .. p.sum() .. ")" + end + return left + end + + function p.conjunction() + local left = p.comparison() + + while peek("name", "and") do + take() + left = "(" .. left .. " and " .. p.comparison() .. ")" + end + return left + end + + function p.expression() + local left = p.conjunction() + + while peek("name", "or") do + take() + left = "(" .. left .. " or " .. p.conjunction() .. ")" + end + return left + end + + local out = p.expression() + + if at <= #tokens then + error("unexpected '" .. tostring(tokens[at].value) .. "' in: " .. text) + end + + return out +end + + +-- An expression as Lua. A number or a boolean given as a value is itself. +function authorExpression(value) + if type(value) == "number" then return tostring(value) + elseif type(value) == "boolean" then + return tostring(value) + elseif value == nil then + return "nil" + end + + return parser(tostring(value)) +end + + +-- ===== Parameters ============================================================================= +-- +-- A parameter's value as a Lua fragment, by the type the manifest declares for it. + +local function fragment(kind, value) + if value == nil then + return nil + end + if kind == "number" or kind == "expression" then + return authorExpression(value) + elseif kind == "boolean" then + return value and "true" or "false" + elseif kind == "entity" then + if value == "self" or value == "other" then + return value + end + return "authorEntity(" .. string.format("%q", value) .. ")" + elseif kind == "scancode" then + return "SCANCODE." .. value .. ".value" + elseif kind == "switch" then + return value + elseif kind == "lua" then + return value end return string.format("%q", tostring(value)) end --- The table a description carries for an entity's look or behaviour, written back out as source. -local function tableSource(t) - local parts = {} - local keys = {} +local function fragments(entry, item) + local out = {} - for key in pairs(t) do - keys[#keys + 1] = key - end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - for _, key in ipairs(keys) do - parts[#parts + 1] = string.format("%s = %s", key, literal(t[key])) + for key, kind in pairs(entry.params or {}) do + out[key] = fragment(kind, item[key]) end - return "{ " .. table.concat(parts, ", ") .. " }" + return out end --- One condition or action, through the manifest entry that owns it. An unknown name is a fault in --- the description rather than in the generated game, so it is reported here and not emitted. +-- ===== Rules ================================================================================== + local function emitOne(set, item, what) - local entry = set[item[1]] + local name = item[1] + local entry = set[name] if entry == nil then - debugPrint(string.format("Author: no %s called '%s'", what, tostring(item[1]))) + debugPrint("Author: no " .. what .. " called '" .. tostring(name) .. "'; skipped") return nil end - return entry.emit(item) + return entry.emit(fragments(entry, item)) end -local function emitConditions(rule) - local tests = {} +-- A condition list is an AND; an entry named any is an OR of what it holds, and an entry named +-- all an AND, nesting as deep as the author likes. +local function emitConditions(list) + local parts = {} - for _, item in ipairs(rule.when or {}) do + for _, item in ipairs(list or {}) do local test = emitOne(AUTHOR.conditions, item, "condition") if test ~= nil then - tests[#tests + 1] = "(" .. test .. ")" + parts[#parts + 1] = test end end - if #tests == 0 then + if list and list.any then + local inner = {} + + for _, item in ipairs(list.any) do + if item[1] then + local test = emitOne(AUTHOR.conditions, item, "condition") + + if test then + inner[#inner + 1] = test + end + else + inner[#inner + 1] = emitConditions(item) + end + end + if #inner > 0 then + parts[#parts + 1] = "(" .. table.concat(inner, " or ") .. ")" + end + end + if list and list.all then + parts[#parts + 1] = emitConditions(list.all) + end + if #parts == 0 then return "true" end - -- Every condition on a rule has to hold. A rule needing "either" is two rules, which reads - -- better in a list than a nested group does. - return table.concat(tests, " and ") + return table.concat(parts, " and ") +end + + +local function ruleWaits(rule) + for _, item in ipairs(rule.act or {}) do + local entry = AUTHOR.actions[item[1]] + + if entry and entry.waits then + return true + end + end + + return false end local function emitRule(out, rule, index) - out[#out + 1] = string.format("%s-- %s", INDENT, rule.note or ("rule " .. index)) - out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule)) + local event = AUTHOR.events[rule.on or "frame"] + local filter = {} + + if event == nil then + debugPrint("Author: no event called '" .. tostring(rule.on) .. "'; rule " .. index .. " skipped") + return + end + for key, kind in pairs(event.filter) do + if rule[key] ~= nil then + filter[#filter + 1] = key .. " = " .. fragment(kind, rule[key]) + end + end + out[#out + 1] = INDENT .. "{" + out[#out + 1] = INDENT .. INDENT .. string.format("note = %q, on = %q,", rule.note or ("rule " .. index), rule.on or "frame") + out[#out + 1] = INDENT .. INDENT .. "filter = { " .. table.concat(filter, ", ") .. " }," + out[#out + 1] = INDENT .. INDENT .. string.format("each = %s, room = %s, waits = %s, controls = %s, interrupt = %s, guarded = %s,", + rule.each and string.format("%q", rule.each) or "nil", + rule.room and string.format("%q", rule.room) or "nil", + tostring(ruleWaits(rule)), + tostring(rule.controls ~= false), + tostring(rule.interrupt == true), + tostring((rule.when ~= nil) and ((#rule.when > 0) or (rule.when.any ~= nil) or (rule.when.all ~= nil)))) + out[#out + 1] = INDENT .. INDENT .. "run = function(self, other, event)" + out[#out + 1] = INDENT .. INDENT .. INDENT .. "if " .. emitConditions(rule.when) .. " then" for _, item in ipairs(rule.act or {}) do local statement = emitOne(AUTHOR.actions, item, "action") if statement ~= nil then - out[#out + 1] = INDENT .. INDENT .. statement + out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. statement end end - out[#out + 1] = INDENT .. "end" - out[#out + 1] = "" + out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. "return true" + out[#out + 1] = INDENT .. INDENT .. INDENT .. "end" + out[#out + 1] = INDENT .. INDENT .. INDENT .. "return false" + out[#out + 1] = INDENT .. INDENT .. "end" + out[#out + 1] = INDENT .. "}," end --- The whole game, as source. Returns a string; the caller decides where it goes. --- --- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing --- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is --- the engine's, as it is for every game ever written for Singe. -function authorCompile(game) - local out = {} +-- A dialogue: its start and its nodes; a choice's when is an expression and its act a list of +-- 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 + 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", + node.text and string.format("%q", node.text) or "nil", + node.seconds and tostring(node.seconds) or "nil", + node.next and string.format("%q", node.next) or "nil") + for _, choice in ipairs(node.choices or {}) do + local parts = { string.format("text = %q", choice.text or "..."), "next = " .. (choice.next and string.format("%q", choice.next) or "nil"), "once = " .. tostring(choice.once == true) } - out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description") - out[#out + 1] = "-- " .. (game.title or "Untitled") - out[#out + 1] = "--" - out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then" - out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other." - out[#out + 1] = "" - out[#out + 1] = 'dofile("Singe/Framework.singe")' - -- The game finds its own directory rather than trusting DIR. Framework sets DIR from the - -- script the *engine was launched with*, which is this file only when the game is played - -- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would - -- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the - -- 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] = "" - - out[#out + 1] = "authorBegin({" - for _, layer in ipairs(game.layers or {}) do - out[#out + 1] = INDENT .. tableSource(layer) .. "," - end - out[#out + 1] = "})" - out[#out + 1] = "" - - for _, entity in ipairs(game.entities or {}) do - local parts = {} - - parts[#parts + 1] = string.format("id = %q", entity.id) - parts[#parts + 1] = string.format("x = %s, y = %s", entity.x or 0, entity.y or 0) - parts[#parts + 1] = "look = " .. tableSource(entity.look) - if entity.behaviours ~= nil and #entity.behaviours > 0 then - local made = {} - - for _, b in ipairs(entity.behaviours) do - made[#made + 1] = tableSource(b) + if choice.when then + parts[#parts + 1] = "when = function() local self, other, event = nil, nil, {} return " .. emitConditions(type(choice.when) == "table" and choice.when or { { "test", expr = choice.when } }) .. " end" end - parts[#parts + 1] = "behaviours = { " .. table.concat(made, ", ") .. " }" + if choice.act then + local body = {} + + for _, item in ipairs(choice.act) do + local statement = emitOne(AUTHOR.actions, item, "action") + + if statement then + body[#body + 1] = statement + end + end + parts[#parts + 1] = "run = function() local self, other, event = nil, nil, {} " .. table.concat(body, " ") .. " end" + end + out[#out + 1] = INDENT .. INDENT .. INDENT .. "{ " .. table.concat(parts, ", ") .. " }," end - out[#out + 1] = "authorMake({ " .. table.concat(parts, ", ") .. " })" + out[#out + 1] = INDENT .. INDENT .. "} }," end - out[#out + 1] = "" - - out[#out + 1] = "-- The rules, in the order they were written. Every one is tested every frame." - out[#out + 1] = "local function rules()" - for index, rule in ipairs(game.rules or {}) do - emitRule(out, rule, index) - end - out[#out + 1] = "end" - out[#out + 1] = "" - - out[#out + 1] = "onKeyPressed = authorKeyDown" - out[#out + 1] = "onKeyReleased = authorKeyUp" - out[#out + 1] = "onInputPressed = authorSwitchDown" - out[#out + 1] = "onInputReleased = authorSwitchUp" - out[#out + 1] = "" - out[#out + 1] = "function onOverlayUpdate()" - out[#out + 1] = INDENT .. "authorFrame(rules)" - out[#out + 1] = "" - out[#out + 1] = INDENT .. "return OVERLAY_UPDATED" - out[#out + 1] = "end" - out[#out + 1] = "" - - return table.concat(out, "\n") -end - - --- Compiles a description file to a game beside it. Returns the path written. --- Copies a file through the VFS, so the source may be inside a database and the destination is an --- ordinary file on disc. Used to put the runtime beside a released game. -function authorCopy(fromPath, toPath) - local input = io.open(fromPath, "rb") - local output - - if input == nil then - debugPrint("Author: cannot read " .. tostring(fromPath)) - return false - end - output = io.open(toPath, "wb") - if output == nil then - input:close() - debugPrint("Author: cannot write " .. tostring(toPath)) - return false - end - output:write(input:read("a")) - input:close() - output:close() - - return true -end - - --- The directory part of a path, with its separator, or "" for a bare name. -local function directoryOf(path) - return (string.match(path, "^(.*[/\\])") or "") -end - - --- Compiles a description and puts the runtime beside the result, because that is what the result --- loads. Building without copying it would produce a game that cannot start. -function authorBuild(descriptionFile, outputFile) - local chunk = assert(loadfile(descriptionFile)) - local game = chunk() - local file - - game.source = descriptionFile - file = assert(io.open(outputFile, "w")) - file:write(authorCompile(game)) - file:close() - authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe") - - return outputFile + out[#out + 1] = INDENT .. "} }," end -- ===== Reading and writing a description ====================================================== -- -- The editor produces descriptions, so the format has to survive a round trip: load, change --- nothing, save, and the result must compile to the same game. scene54 asserts exactly that. +-- nothing, save, and the result must compile to the same game. + +local function literal(value) + if type(value) == "string" then + return string.format("%q", value) + end + + return tostring(value) +end local function indentOf(depth) @@ -256,10 +524,10 @@ local function indentOf(depth) end --- Whether a table holds only leaves, in which case it is written on one line. A rule reads far --- better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed. +-- Whether a table holds only leaves, in which case it is written on one line. A condition reads +-- far better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed. local function isLeaf(t) - for key, value in pairs(t) do + for _, value in pairs(t) do if type(value) == "table" then return false end @@ -277,9 +545,6 @@ local function valueSource(value, depth) local parts = {} local keys = {} local count = 0 - local sep - local open - local close if type(value) ~= "table" then return literal(value) @@ -296,7 +561,12 @@ local function valueSource(value, depth) end table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) for _, key in ipairs(keys) do - parts[#parts + 1] = string.format("%s = %s", key, valueSource(value[key], depth + 1)) + local name = tostring(key) + + if not name:match("^[%a_][%w_]*$") then + name = "[" .. literal(key) .. "]" + end + parts[#parts + 1] = string.format("%s = %s", name, valueSource(value[key], depth + 1)) end if #parts == 0 then return "{}" @@ -304,11 +574,8 @@ local function valueSource(value, depth) if isLeaf(value) then return "{ " .. table.concat(parts, ", ") .. " }" end - sep = ",\n" .. indentOf(depth + 1) - open = "{\n" .. indentOf(depth + 1) - close = "\n" .. indentOf(depth) .. "}" - return open .. table.concat(parts, sep) .. close + return "{\n" .. indentOf(depth + 1) .. table.concat(parts, ",\n" .. indentOf(depth + 1)) .. "\n" .. indentOf(depth) .. "}" end @@ -341,3 +608,215 @@ function authorSave(game, path) return path end + + +function authorCopy(fromPath, toPath) + local input = io.open(fromPath, "rb") + local output + + if input == nil then + return false + end + output = io.open(toPath, "wb") + if output == nil then + input:close() + return false + end + output:write(input:read("a")) + input:close() + output:close() + + return true +end + + +-- ===== Checking =============================================================================== +-- +-- What the compiler can tell an author before the game runs: a kind 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 function problem(text) + problems[#problems + 1] = text + end + + local function checkItems(set, list, what, where) + for _, item in ipairs(list or {}) do + local entry = set[item[1]] + + if entry == nil then + problem(where .. ": no " .. what .. " called '" .. tostring(item[1]) .. "'") + else + for key, kind in pairs(entry.params) do + if (kind == "number" or kind == "expression") and (type(item[key]) == "string") then + local ok, err = pcall(parser, item[key]) + + 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]) .. "'") + end + end + end + end + end + + -- A field the manifest does not declare is a misspelling nine times in ten, and it is + -- otherwise silently ignored. + local function checkFields(t, declared, skip, where) + for key in pairs(t or {}) do + if (type(key) == "string") and (declared[key] == nil) and not skip[key] then + problem(where .. ": '" .. key .. "' is not something it takes") + end + end + end + + for name, kind in pairs(kinds) do + local look = kind.look and AUTHOR.looks[kind.look.kind] or nil + + if kind.look and (look == nil) then + problem("kind " .. name .. ": no look called '" .. tostring(kind.look.kind) .. "'") + elseif look then + checkFields(kind.look, look.params, { kind = true }, "kind " .. name .. "'s look") + end + for _, b in ipairs(kind.behaviours or {}) do + local behaviour = AUTHOR.behaviours[b.kind] + + if behaviour == nil then + problem("kind " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'") + else + checkFields(b, behaviour.params, { kind = true }, "kind " .. name .. "'s " .. b.kind) + end + end + checkFields(kind, { look = true, vars = true, behaviours = true }, {}, "kind " .. 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) .. "'") + end + end + end + for index, rule in ipairs(game.rules or {}) do + local where = "rule " .. index .. " (" .. tostring(rule.note or "") .. ")" + + if rule.on and (AUTHOR.events[rule.on] == nil) then + problem(where .. ": no event called '" .. tostring(rule.on) .. "'") + 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) .. "'") + end + checkItems(AUTHOR.conditions, rule.when, "condition", where) + checkItems(AUTHOR.conditions, rule.when and rule.when.any, "condition", where) + checkItems(AUTHOR.actions, rule.act, "action", where) + end + + return problems +end + + +-- ===== The whole game ========================================================================= + +-- The whole game, as source. Returns a string; the caller decides where it goes. +-- +-- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing +-- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is +-- the engine's, as it is for every game ever written for Singe. +function authorCompile(game) + local out = {} + local data = {} + + for key, value in pairs(game) do + if (key ~= "rules") and (key ~= "source") and (key ~= "dialogues") then + data[key] = value + end + end + out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description") + out[#out + 1] = "-- " .. (game.title or "Untitled") + out[#out + 1] = "--" + out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then" + out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other." + out[#out + 1] = "" + out[#out + 1] = 'dofile("Singe/Framework.singe")' + -- The game finds its own directory rather than trusting DIR. Framework sets DIR from the + -- script the *engine was launched with*, which is this file only when the game is played + -- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would + -- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the + -- 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] = "" + 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) .. ")" + out[#out + 1] = "" + out[#out + 1] = "-- The rules. Each is a trigger, its filter, and a function that tests the conditions and" + out[#out + 1] = "-- does the actions; one with an action that waits runs as a coroutine." + out[#out + 1] = "authorRules({" + for index, rule in ipairs(game.rules or {}) do + emitRule(out, rule, index) + end + out[#out + 1] = "})" + out[#out + 1] = "" + 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) + 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] = "" + out[#out + 1] = "function onOverlayUpdate()" + out[#out + 1] = INDENT .. "authorFrame()" + out[#out + 1] = "" + out[#out + 1] = INDENT .. "return OVERLAY_UPDATED" + out[#out + 1] = "end" + out[#out + 1] = "" + + return table.concat(out, "\n") +end + + +local function directoryOf(path) + return (string.match(path, "^(.*[/\\])") or "") +end + + +-- Compiles a description file to a game beside it, with the runtime beside that, because that is +-- what the result loads. Returns the path written. +function authorBuild(descriptionFile, outputFile) + local chunk = assert(loadfile(descriptionFile)) + local game = chunk() + local file + + game.source = descriptionFile + for _, problem in ipairs(authorCheck(game)) do + debugPrint("Author: " .. problem) + end + file = assert(io.open(outputFile, "w")) + file:write(authorCompile(game)) + file:close() + authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe") + + return outputFile +end diff --git a/assets/Forge/Forge.rml b/assets/Forge/Forge.rml index 909c990c1..ebba78de5 100644 --- a/assets/Forge/Forge.rml +++ b/assets/Forge/Forge.rml @@ -20,6 +20,9 @@ .row { margin: 2px 8px; padding: 4px 6px; background-color: #242433ff; } .row:hover { background-color: #32324aff; } .row.selected { background-color: #c03c96ff; color: #ffffff; } + .thumb { display: inline-block; vertical-align: middle; margin-right: 6px; width: 24px; text-align: center; color: #ffcf4a; } + .thumb.dim { color: #6a6a80; } + .thumb.swatch { height: 16px; width: 16px; margin-left: 4px; margin-right: 10px; } .part { margin: 1px 8px 1px 18px; padding: 2px 5px; background-color: #1b1b28ff; color: #aeb4c8; font-size: 12px; } .part.here { background-color: #4a3a6aff; color: #ffffff; } #detail { margin: 14px 8px; padding: 6px; background-color: #10101aff; color: #b9bfd4; } diff --git a/assets/Forge/Forge.singe b/assets/Forge/Forge.singe index 34e07d60f..ea98da368 100644 --- a/assets/Forge/Forge.singe +++ b/assets/Forge/Forge.singe @@ -24,25 +24,26 @@ --- Forge: the authoring tool (PLAN section 58). +-- Forge: the authoring tool (FORGE.md). -- -- It is a Singe game. Nothing here is a preview: the canvas is the same overlay, at the same -- coordinates, that the game will be played in, so what is placed is what is seen. -- --- The layout comes from the spike recorded in that section. Chrome is an RmlUi document, which is --- what RmlUi is good at; the canvas beside it is drawn into the overlay and picked with --- collidePointRect, which is what Singe has always done with a pointer and a set of rectangles. --- They compose because the engine offers a button to the GUI first and passes on what it did not --- use, while pointer motion is never consumed at all. +-- Chrome is an RmlUi document, which is what RmlUi is good at; the canvas beside it is drawn into +-- the overlay and picked with collidePointRect, which is what Singe has always done with a pointer +-- and a set of rectangles. They compose because the engine offers a button to the GUI first and +-- passes on what it did not use, while pointer motion is never consumed at all. +-- +-- The panel shows one of four lists -- the room's entities, the kinds, the rules, the room's tracks +-- -- and TAB cycles them. Whatever is selected is edited the same way: ENTER walks its fields and +-- each is typed. The fields come from the manifest in Author.singe, so a new look, behaviour, +-- condition, action, or event is editable the moment it is declared. -- -- The file is two things. Everything up to the last section is a library: a scene that drives -- the editor sets FORGE_LIBRARY before loading it and supplies its own callbacks. The last section --- is Forge as a game -- the file chooser, the engine callbacks and the play button -- and runs only +-- is Forge as a game -- the file chooser, the engine callbacks, and the play button -- and runs only -- when nothing has said otherwise. --- Framework is wanted for SCANCODE, which the key handling below names; AuthorCompile brings in --- the runtime and the manifest. A generated game loads Framework for itself, so the editor only --- discovered it needed it when the rule editor started naming keys. dofile("Singe/Framework.singe") dofile("Forge/AuthorCompile.singe") @@ -52,36 +53,82 @@ local lfs = require("lfs") FORGE = { game = nil, -- The description being edited. path = nil, - selected = nil, -- Index into game.entities. + room = 1, -- Index into game.rooms. + mode = "entities", -- entities, kinds, rules, or tracks. + selected = nil, -- Index into the room's entities. + kindName = nil, -- The selected kind. + rule = 1, -- Selected rule, + part = 0, -- and the selected condition or action within it; 0 is the rule itself. + track = 1, -- Selected track in the room, + key = 0, -- and the selected key within it; 0 is the track itself. + cursor = 0, -- The timeline's position: a disc frame or a time. dirty = false, gui = nil, document = nil, - held = nil, -- The entity being dragged. + held = nil, -- The entity being dragged, or { key = ... } for a track box. grabX = 0, grabY = 0, panelX = 0, -- The chrome slides sideways so nothing is permanently under it. panelDrag = nil, -- Offset from the panel's left edge while it is being dragged. - mode = "entities", -- or "rules". The panel is narrow; one list at a time reads better. - rule = 1, -- Selected rule. - part = 0, -- Selected condition or action within it; 0 is the rule itself. - lastField = nil, -- The parameter ENTER edited last, so the next ENTER moves on from it. + lastField = nil, -- The field ENTER edited last, so the next ENTER moves on from it. editing = nil, -- { key, text, was, set }: see "Typing a value". picker = nil, -- A list being chosen from: see "Pickers". undo = {}, -- Copies of the description, newest last. redo = {}, -- What undo took away, newest last; any other change empties it. sprites = {}, -- Sprite looks loaded for drawing, by file name; false when missing. + sheets = {}, -- The same files cut into frames, by file and frame count, for turned sheets. + video = nil, -- The disc layer's file, loaded so the timeline can scrub it. + videoFile = nil, + scene = nil, -- The 3D preview: { nodes, camera, yaw, pitch, distance, stamp }. + dialogue = nil, -- The selected dialogue's name, + dnode = nil, -- the selected node's name within it, + dchoice = 0, -- and the selected choice (0 is the node itself). + dact = 0, -- The selected action of that choice (0 is the choice itself). + drawing = nil, -- A polygon being drawn on the canvas: { points }. closing = false, -- ESC has been pressed once on an unsaved description. message = "loaded" } -local PANEL_W = 190 -- Matches Forge.rml; the canvas starts to the right of it. -local HANDLE = 6 -- Half the size of the square drawn at an entity's own position. -local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just outside its edge. -local TAB_H = 54 -local TAB_Y = 10 -local UNDO_MAX = 40 -- Steps kept. A description is small; forty copies are nothing. -local NEW_W = 40 -- The box a new entity wears until it is given a look. -local NEW_H = 40 +local PANEL_W = 190 -- Matches Forge.rml; the canvas starts to the right of it. +local HANDLE = 6 -- Half the size of the square drawn at an entity's own position. +local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just outside its edge. +local TAB_H = 54 +local TAB_Y = 10 +local UNDO_MAX = 40 -- Steps kept. A description is small; forty copies are nothing. +local NEW_W = 40 -- The box a new kind wears until it is given a look. +local NEW_H = 40 +local STRIP_H = 34 -- The timeline along the bottom of the canvas. +local STRIP_PAD = 8 +local MODES = { "entities", "kinds", "rules", "tracks", "dialogues" } +local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true } +local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks. +local FRAME_STEP = 1 +local FRAME_LEAP = 10 +local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it. +local GIZMO_LEN = 1.5 -- World units each gizmo arm reaches from the selected entity. +local GIZMO_GRAB = 10 -- Pixels a press may be from an arm's tip to take it. +local TURN_REACH = 18 -- Pixels the 2D turn handle stands off the selected entity's box. +local THUMB = 24 -- The kinds panel's thumbnails fit a square this many pixels across. +local ORBIT_STEP = 10 -- Degrees the editor camera turns per key. +local ZOOM_STEP = 1.15 -- How much closer or farther per key. +local sceneBegun = false -- The 3D scene is switched on once per run of the editor. + +FORGE_DEFAULTS = { -- What a new parameter starts as, by the manifest's type. + number = 0, + string = "", + boolean = true, + entity = "self", + kind = "", + scancode = "SPACE", + switch = "SWITCH_BUTTON1", + expression = '""', + lua = "-- your Lua here", + file = "", + state = "idle", + track = "", + room = "", + table = nil +} local function element(id) @@ -89,6 +136,11 @@ local function element(id) end +function forgeRoom() + return FORGE.game.rooms[FORGE.room] +end + + -- A sprite look's image, loaded once and kept, so the canvas can show it. A file that is not -- there answers nil rather than ending the editor the way spriteLoad would: the entity is drawn -- as a box until the name is right. @@ -105,40 +157,51 @@ local function spriteFor(file) end --- An entity's box in overlay coordinates. A look without a size still gets one, so that a text --- entity can be picked up and moved like anything else; a sprite is as big as its image. -function forgeBounds(entity) - local look = entity.look or {} - local w = look.w or 90 - local h = look.h or 20 +-- The same file cut into frames, for drawing one of them turned. +local function sheetFor(file, frames) + local key = file .. "#" .. frames - if look.kind == "sprite" then - local sprite = spriteFor(look.file or "") - - if sprite ~= nil then - w = spriteGetWidth(sprite) - h = spriteGetHeight(sprite) - end + if FORGE.sheets[key] == nil then + FORGE.sheets[key] = spriteFor(file) and spriteLoadFrames(frames, file) or false end - return entity.x - w / 2, entity.y - h / 2, w, h + return FORGE.sheets[key] or nil end -function forgeSelect(index) - FORGE.selected = index - FORGE.lastField = nil - forgeRefresh() +-- The sorted keys of a table, so fields always read in the same order. +local function sortedKeys(t) + local keys = {} + + for key in pairs(t or {}) do + keys[#keys + 1] = key + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + + return keys end --- The topmost entity under a point, so overlapping things pick the one drawn last. -function forgePick(x, y) - for index = #FORGE.game.entities, 1, -1 do - local bx, by, bw, bh = forgeBounds(FORGE.game.entities[index]) +local function copyOf(value) + local out - if collidePointRect(x, y, bx, by, bw, bh) then - return index + if type(value) ~= "table" then + return value + end + out = {} + for key, item in pairs(value) do + out[key] = copyOf(item) + end + + return out +end + + +-- The behaviour of a kind a kind carries, if any. +local function behaviourOf(kind, name) + for _, b in ipairs(kind.behaviours or {}) do + if b.kind == name then + return b end end @@ -159,8 +222,102 @@ local function escape(text) end +-- A path RmlUi takes as it is: the vfs resolves a document's images against the document, so a +-- game file is named from the game's own directory, which is where the editor runs. +local function rooted(file) + if file:match("^[/\\]") or file:match("^%a:") then + return file + end + + return lfs.currentdir() .. "/" .. file +end + + +-- What a kind looks like, small, for its row in the panel: the sprite itself (one frame of a +-- sheet), or a swatch of the box's colour. +local function thumbnail(look) + if look.kind == "sprite" then + local sprite = spriteFor(look.file or "") + + if sprite ~= nil then + local w, h = spriteGetWidth(sprite) / (look.frames or 1), spriteGetHeight(sprite) + local fit = THUMB / math.max(w, h) + local rect = ((look.frames or 1) > 1) and string.format(' rect="0 0 %d %d"', w, h) or "" + + return string.format('', escape(rooted(look.file)), rect, math.floor(w * fit + 0.5), math.floor(h * fit + 0.5)) + end + elseif look.kind == "text" then + return 'Aa' + elseif (look.kind == "none") or (look.kind == "grid") then + return '..' + end + + return string.format('', look.r or 180, look.g or 180, look.b or 190) +end + + +-- ===== Entities on the canvas ================================================================= + +-- What an entity looks like: its kind's look, with anything the entry overrides. +function forgeLook(entry) + local kind = FORGE.game.kinds[entry.kind] + + return (kind and kind.look) or { kind = "none" } +end + + +-- An entity's box in overlay coordinates. A look without a size still gets one, so that a text +-- entity can be picked up and moved like anything else; a sprite is as big as its image. +function forgeBounds(entry) + local look = forgeLook(entry) + local w = look.w or 40 + local h = look.h or 20 + + if look.kind == "sprite" then + local sprite = spriteFor(look.file or "") + + if sprite ~= nil then + w = spriteGetWidth(sprite) / (look.frames or 1) + h = spriteGetHeight(sprite) + end + elseif look.kind == "text" then + w = math.max(60, #((entry.vars and entry.vars.text) or look.text or "") * 9) + end + + w = w * (entry.scale or 1) + h = h * (entry.scale or 1) + + return (entry.x or 0) - w / 2, (entry.y or 0) - h / 2, w, h +end + + +function forgeSelect(index) + FORGE.selected = index + FORGE.lastField = nil + forgeRefresh() +end + + +-- The topmost entity under a point, so overlapping things pick the one drawn last. +function forgePick(x, y) + local entities = forgeRoom().entities + + for index = #entities, 1, -1 do + local bx, by, bw, bh = forgeBounds(entities[index]) + + if collidePointRect(x, y, bx, by, bw, bh) then + return index + end + end + + return nil +end + + +-- ===== The panel ============================================================================== + -- The fields of whatever is selected, one row each, with the one ENTER is on marked. This is the --- detail panel for an entity and for a condition or action alike: the same rows, the same keys. +-- detail panel for an entity, a kind, a rule, and a key alike: the same rows, the same keys. local function fieldRows(fields) local rows = {} @@ -174,52 +331,74 @@ local function fieldRows(fields) end -local function refreshEntities() - local rows = {} - local fields = forgeFields() +local function detail(fields, head) + local rows = head or "" - for index, entity in ipairs(FORGE.game.entities) do + if fields ~= nil then + rows = rows .. fieldRows(fields) + end + element("detail").inner_rml = rows .. '
' .. escape(FORGE.message) .. "
" +end + + +local function refreshEntities() + local rows = {} + local room = forgeRoom() + + for index, entry in ipairs(room.entities) do local class = (index == FORGE.selected) and "row selected" or "row" - rows[#rows + 1] = string.format('
%s
', index, class, escape(entity.id)) + rows[#rows + 1] = string.format('
%s
', index, class, escape((entry.id or "?") .. " (" .. tostring(entry.kind) .. ")")) end if #rows == 0 then - rows[1] = '
no entities yet: A adds one
' + rows[1] = '
no entities here: A adds one
' end element("list").inner_rml = table.concat(rows) - for index = 1, #FORGE.game.entities do + for index = 1, #room.entities do guiSetHandler(FORGE.gui, FORGE.document, "e" .. index, "click", function() forgeSelect(index) end) end - if fields ~= nil then - element("detail").inner_rml = fieldRows(fields) .. '
' .. escape(FORGE.message) .. "
" - else - element("detail").inner_rml = "nothing selected
" .. escape(FORGE.message) + detail(forgeFields(), string.format('
room %d of %d: %s
', FORGE.room, #FORGE.game.rooms, escape(room.name))) +end + + +local function refreshKinds() + local rows = {} + + for _, name in ipairs(sortedKeys(FORGE.game.kinds)) do + local class = (name == FORGE.kindName) and "row selected" or "row" + + rows[#rows + 1] = string.format('
%s%s
', class, thumbnail(FORGE.game.kinds[name].look or {}), escape(name)) end + if #rows == 0 then + rows[1] = '
no kinds yet: A adds one
' + end + element("list").inner_rml = table.concat(rows) + detail(forgeFields()) end -- The event sheet. The selected rule is opened in place and its conditions and actions listed -- under it, because a rule only means anything as a whole: seeing "when" without "then" is no use. local function refreshRules() - local rows = {} - local fields = forgeFields() + local rows = {} - for index, rule in ipairs(FORGE.game.rules or {}) do + for index, rule in ipairs(FORGE.game.rules) do local class = (index == FORGE.rule) and "row selected" or "row" rows[#rows + 1] = string.format('
%d. %s
', class, index, escape(rule.note or "rule")) if index == FORGE.rule then local parts = forgeParts(rule) + rows[#rows + 1] = string.format('
on %s%s
', escape(rule.on or "frame"), rule.each and (" each " .. escape(rule.each)) or "") for at, part in ipairs(parts) do local mark = (at == FORGE.part) and "part here" or "part" rows[#rows + 1] = string.format('
%s %s
', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item))) end if #parts == 0 then - rows[#rows + 1] = '
(empty: C adds a condition, T an action)
' + rows[#rows + 1] = '
(C adds a condition, T an action)
' end end end @@ -227,22 +406,344 @@ local function refreshRules() rows[1] = '
no rules yet: N adds one
' end element("list").inner_rml = table.concat(rows) - element("detail").inner_rml = string.format("rule %d of %d
%s%s", FORGE.rule, #(FORGE.game.rules or {}), (fields ~= nil) and fieldRows(fields) or "", '
' .. escape(FORGE.message) .. "
") + detail(forgeFields(), string.format('
rule %d of %d
', FORGE.rule, #FORGE.game.rules)) end --- A picker takes the list over while it is up: the vocabulary is what is being chosen from, and +local function refreshTracks() + local rows = {} + local tracks = forgeRoom().tracks or {} + + for index, track in ipairs(tracks) do + local class = (index == FORGE.track) and "row selected" or "row" + + rows[#rows + 1] = string.format('
%s (by %s)
', class, escape(track.name or "?"), escape(track.key or "frame")) + if index == FORGE.track then + for at, key in ipairs(forgeTrackKeys(track)) do + local mark = (at == FORGE.key) and "part here" or "part" + + if track.points then + rows[#rows + 1] = string.format('
at %s: %s, %s, %s%s
', mark, tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.z), key.stop and (" stop " .. escape(key.stop)) or "") + else + rows[#rows + 1] = string.format('
at %s: %s,%s %sx%s
', mark, tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.w), tostring(key.h)) + end + end + if #forgeTrackKeys(track) == 0 then + rows[#rows + 1] = '
(K adds a key at the cursor)
' + end + end + end + if #rows == 0 then + rows[1] = '
no tracks here: N adds one
' + end + element("list").inner_rml = table.concat(rows) + detail(forgeFields(), string.format('
cursor at %s (, . step [ ] leap)
', tostring(FORGE.cursor))) +end + + +-- A picker takes the list over while it is up: the manifest is what is being chosen from, and -- the panel is too narrow to show it beside anything else. local function refreshPicker() - local rows = {} + local rows = {} + local shown = forgePickItems() - for at, item in ipairs(FORGE.picker.items) do + 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(FORGE.picker.items[FORGE.picker.at].help or "") .. '
UP / DOWN choose, ENTER takes it, ESC leaves it
' + element("detail").inner_rml = escape(FORGE.picker.title) .. "
" .. escape((shown[FORGE.picker.at] or {}).help or "") .. '
type to narrow: ' .. escape(FORGE.picker.filter) .. '_
UP / DOWN choose, ENTER takes it, ESC leaves it
' +end + + +-- The dialogues, as an outline: every dialogue, the selected one's nodes under it, the selected +-- node's choices under that, and a choice's actions under that. +function forgeDialogueRows() + local rows = {} + + for _, name in ipairs(sortedKeys(FORGE.game.dialogues)) do + local dialogue = FORGE.game.dialogues[name] + + rows[#rows + 1] = { level = "dialogue", dialogue = name, label = name } + if name == FORGE.dialogue then + for _, nodeName in ipairs(sortedKeys(dialogue.nodes)) do + local node = dialogue.nodes[nodeName] + + rows[#rows + 1] = { level = "node", dialogue = name, node = nodeName, label = nodeName .. ((nodeName == dialogue.start) and " (start)" or "") .. ": " .. (node.who and (node.who .. ": ") or "") .. tostring(node.text or "") } + if nodeName == FORGE.dnode then + for index, choice in ipairs(node.choices or {}) do + rows[#rows + 1] = { level = "choice", dialogue = name, node = nodeName, choice = index, label = index .. ". " .. tostring(choice.text or "") .. (choice.next and (" -> " .. choice.next) or "") } + if index == FORGE.dchoice then + for at, item in ipairs(choice.act or {}) do + rows[#rows + 1] = { level = "act", dialogue = name, node = nodeName, choice = index, act = at, label = "then " .. forgePartText(item) } + end + end + end + end + end + end + end + + return rows +end + + +local function dialogueRowSelected(row) + if row.level == "dialogue" then + return (row.dialogue == FORGE.dialogue) and (FORGE.dnode == nil) + elseif row.level == "node" then + return (row.node == FORGE.dnode) and (FORGE.dchoice == 0) + elseif row.level == "choice" then + return (row.node == FORGE.dnode) and (row.choice == FORGE.dchoice) and (FORGE.dact == 0) + end + + return (row.node == FORGE.dnode) and (row.choice == FORGE.dchoice) and (row.act == FORGE.dact) +end + + +local function refreshDialogues() + local rows = {} + + for _, row in ipairs(forgeDialogueRows()) do + local class = ({ dialogue = "row", node = "part", choice = "part", act = "part" })[row.level] + + if dialogueRowSelected(row) then + class = (row.level == "dialogue") and "row selected" or "part here" + end + rows[#rows + 1] = string.format('
%s
', class, escape(row.label)) + end + if #rows == 0 then + rows[1] = '
no dialogues yet: A adds one
' + end + element("list").inner_rml = table.concat(rows) + detail(forgeFields()) +end + + +-- Moves the dialogue selection to a row of the outline. +local function dialogueSelect(row) + FORGE.dialogue = row.dialogue + FORGE.dnode = row.node + FORGE.dchoice = row.choice or 0 + FORGE.dact = row.act or 0 + FORGE.lastField = nil +end + + +-- A step up or down the outline. +function forgeDialogueStep(by) + local rows = forgeDialogueRows() + local at = 0 + + for index, row in ipairs(rows) do + if dialogueRowSelected(row) then + at = index + end + end + at = math.max(1, math.min(#rows, at + by)) + if rows[at] then + dialogueSelect(rows[at]) + end + forgeRefresh() +end + + +-- A name no dialogue, or no node of a dialogue, has. +local function uniqueName(set, stem) + local n = 1 + + if set[stem] == nil then + return stem + end + while set[stem .. n] ~= nil do + n = n + 1 + end + + return stem .. n +end + + +-- A adds at the level selected: a dialogue, a node in it, or a choice in the node. +function forgeDialogueAdd() + FORGE.game.dialogues = FORGE.game.dialogues or {} + forgeRemember() + if FORGE.dialogue == nil or FORGE.game.dialogues[FORGE.dialogue] == nil then + local name = uniqueName(FORGE.game.dialogues, "talk") + + FORGE.game.dialogues[name] = { start = "start", nodes = { start = { who = "", text = "...", choices = {} } } } + FORGE.dialogue = name + FORGE.dnode = nil + FORGE.message = "added dialogue " .. name + elseif FORGE.dnode == nil then + local dialogue = FORGE.game.dialogues[FORGE.dialogue] + local name = uniqueName(dialogue.nodes, "node") + + dialogue.nodes[name] = { who = "", text = "...", choices = {} } + FORGE.dnode = name + FORGE.dchoice = 0 + FORGE.message = "added node " .. name + else + local node = FORGE.game.dialogues[FORGE.dialogue].nodes[FORGE.dnode] + + node.choices = node.choices or {} + node.choices[#node.choices + 1] = { text = "..." } + FORGE.dchoice = #node.choices + FORGE.dact = 0 + FORGE.message = "added a choice" + end + FORGE.dirty = true + FORGE.lastField = nil + forgeRefresh() +end + + +function forgeDialogueDelete() + local dialogues = FORGE.game.dialogues or {} + local dialogue = dialogues[FORGE.dialogue or ""] + + if dialogue == nil then + return false + end + forgeRemember() + if FORGE.dnode == nil then + dialogues[FORGE.dialogue] = nil + FORGE.dialogue = sortedKeys(dialogues)[1] + FORGE.message = "dialogue deleted" + elseif FORGE.dchoice == 0 then + dialogue.nodes[FORGE.dnode] = nil + FORGE.dnode = nil + FORGE.message = "node deleted" + elseif FORGE.dact == 0 then + table.remove(dialogue.nodes[FORGE.dnode].choices, FORGE.dchoice) + FORGE.dchoice = 0 + FORGE.message = "choice deleted" + else + table.remove(dialogue.nodes[FORGE.dnode].choices[FORGE.dchoice].act, FORGE.dact) + FORGE.dact = 0 + FORGE.message = "action deleted" + end + FORGE.dirty = true + forgeRefresh() + + return true +end + + +-- T adds an action to the selected choice, from the vocabulary. +function forgeDialogueAct(name) + local dialogue = FORGE.game.dialogues[FORGE.dialogue or ""] + local node = dialogue and dialogue.nodes[FORGE.dnode or ""] + local choice = node and node.choices and node.choices[FORGE.dchoice] + local item = forgeMakePart(AUTHOR.actions, name) + + if (choice == nil) or (item == nil) then + return false + end + forgeRemember() + choice.act = choice.act or {} + choice.act[#choice.act + 1] = item + FORGE.dact = #choice.act + FORGE.lastField = nil + FORGE.dirty = true + FORGE.message = "added " .. name + forgeRefresh() + + return true +end + + +-- The fields of the selected dialogue, node, choice, or action. +function forgeDialogueFields() + local dialogues = FORGE.game.dialogues or {} + local dialogue = dialogues[FORGE.dialogue or ""] + + if dialogue == nil then + return nil + end + if FORGE.dnode == nil then + return { + names = { "name", "start" }, + get = function(key) return (key == "name") and FORGE.dialogue or dialogue.start end, + set = function(key, value) + if (key == "name") and (value ~= "") and (dialogues[value] == nil) then + dialogues[value] = dialogue + dialogues[FORGE.dialogue] = nil + FORGE.dialogue = value + elseif key == "start" then + dialogue.start = value + end + FORGE.dirty = true + forgeRefresh() + end + } + end + local node = dialogue.nodes[FORGE.dnode] + + if node == nil then + return nil + end + if FORGE.dchoice == 0 then + return { + names = { "name", "who", "text", "seconds", "next" }, + get = function(key) return (key == "name") and FORGE.dnode or node[key] end, + set = function(key, value) + if key == "name" then + if (value ~= "") and (dialogue.nodes[value] == nil) then + dialogue.nodes[value] = node + dialogue.nodes[FORGE.dnode] = nil + if dialogue.start == FORGE.dnode then + dialogue.start = value + end + FORGE.dnode = value + end + elseif (key == "next" or key == "who") and (value == "") then + node[key] = nil + else + node[key] = value + end + FORGE.dirty = true + forgeRefresh() + end + } + end + local choice = node.choices and node.choices[FORGE.dchoice] + + if choice == nil then + return nil + end + if FORGE.dact == 0 then + return { + names = { "text", "when", "next", "once" }, + get = function(key) return (key == "once") and (choice.once == true) or choice[key] end, + set = function(key, value) + if key == "once" then + choice.once = (value == true) or nil + elseif (key == "when" or key == "next") and (value == "") then + choice[key] = nil + else + choice[key] = value + end + FORGE.dirty = true + forgeRefresh() + end + } + end + local item = choice.act and choice.act[FORGE.dact] + + if item == nil then + return nil + end + + return { + names = forgeFieldNames(item), + get = function(key) return item[key] end, + set = function(key, value) + item[key] = value + FORGE.dirty = true + forgeRefresh() + end + } end @@ -251,39 +752,91 @@ function forgeRefresh() return end element("title").inner_rml = escape(FORGE.game.title or "Untitled") .. (FORGE.dirty and " *" or "") - element("grip").inner_rml = (FORGE.mode == "rules") and "≡ Rules" or "≡ Entities" + element("grip").inner_rml = "≡ " .. FORGE.mode:sub(1, 1):upper() .. FORGE.mode:sub(2) if FORGE.picker ~= nil then refreshPicker() + elseif FORGE.mode == "kinds" then + refreshKinds() elseif FORGE.mode == "rules" then refreshRules() + elseif FORGE.mode == "tracks" then + refreshTracks() + elseif FORGE.mode == "dialogues" then + refreshDialogues() else refreshEntities() end end +-- ===== Opening and closing ==================================================================== + +-- The disc layer's file, if the description has one, loaded and paused so the timeline can show +-- the frame under the cursor. +local function loadVideo() + local file + + for _, layer in ipairs(FORGE.game.layers or {}) do + if (layer.kind == "disc") and layer.file then + file = layer.file + end + end + if file ~= FORGE.videoFile then + if FORGE.video then + videoUnload(FORGE.video) + FORGE.video = nil + end + FORGE.videoFile = file + if file and (lfs.attributes(file, "mode") == "file") then + FORGE.video = videoLoad(file) + videoPause(FORGE.video) + videoSeek(FORGE.video, FORGE.cursor) + end + end +end + + function forgeBegin(path) FORGE.path = path FORGE.game = authorLoad(path) if FORGE.game == nil then return false end - FORGE.game.entities = FORGE.game.entities or {} - FORGE.game.rules = FORGE.game.rules or {} - FORGE.game.layers = FORGE.game.layers or {} - FORGE.selected = nil - FORGE.rule = 1 - FORGE.part = 0 - FORGE.lastField = nil - FORGE.editing = nil - FORGE.picker = nil - FORGE.undo = {} - FORGE.redo = {} - FORGE.dirty = false - FORGE.closing = false - FORGE.message = "loaded" - FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight()) - FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml") + FORGE.game.kinds = FORGE.game.kinds or {} + FORGE.game.rules = FORGE.game.rules or {} + FORGE.game.layers = FORGE.game.layers or {} + FORGE.game.rooms = FORGE.game.rooms or {} + if #FORGE.game.rooms == 0 then + FORGE.game.rooms[1] = { name = "main", entities = {} } + end + for _, room in ipairs(FORGE.game.rooms) do + room.entities = room.entities or {} + room.tracks = room.tracks or {} + end + FORGE.room = 1 + FORGE.mode = "entities" + FORGE.selected = nil + FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] + FORGE.dialogue = sortedKeys(FORGE.game.dialogues or {})[1] + FORGE.dnode = nil + FORGE.dchoice = 0 + FORGE.dact = 0 + FORGE.drawing = nil + FORGE.rule = 1 + FORGE.part = 0 + FORGE.track = 1 + FORGE.key = 0 + FORGE.cursor = 0 + FORGE.lastField = nil + FORGE.editing = nil + FORGE.picker = nil + FORGE.undo = {} + FORGE.redo = {} + FORGE.dirty = false + FORGE.closing = false + FORGE.message = "loaded" + FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight()) + FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml") guiSetInput(FORGE.gui, true) guiSetHandler(FORGE.gui, FORGE.document, "grip", "mousedown", function() local x = mouseGetPosition(0) @@ -295,6 +848,8 @@ function forgeBegin(path) FORGE.message = "panel at " .. FORGE.panelX forgeRefresh() end) + loadVideo() + forgeSceneBegin() forgePanelTo(0) forgeRefresh() @@ -307,31 +862,45 @@ function forgeClose() if FORGE.gui ~= nil then guiDelete(FORGE.gui) end - for _, sprite in pairs(FORGE.sprites) do - if sprite then - spriteUnload(sprite) + for _, cache in ipairs({ FORGE.sprites, FORGE.sheets }) do + for _, sprite in pairs(cache) do + if sprite then + spriteUnload(sprite) + end end end - FORGE.gui = nil - FORGE.document = nil - FORGE.game = nil - FORGE.path = nil - FORGE.sprites = {} - FORGE.editing = nil - FORGE.picker = nil - FORGE.held = nil - FORGE.undo = {} - FORGE.redo = {} + if FORGE.video then + videoUnload(FORGE.video) + end + if FORGE.scene then + forgeSceneClear() + nodeDelete(FORGE.scene.camera) + cameraSet(-1) + FORGE.scene = nil + end + FORGE.gui = nil + FORGE.document = nil + FORGE.game = nil + FORGE.path = nil + FORGE.sprites = {} + FORGE.sheets = {} + FORGE.video = nil + FORGE.videoFile = nil + FORGE.editing = nil + FORGE.picker = nil + FORGE.held = nil + FORGE.undo = {} + FORGE.redo = {} end --- Moves the selected entity. The description is the truth; nothing is cached anywhere else, so --- saving is only writing it back out. +-- Moves an entity. The description is the truth; nothing is cached anywhere else, so saving is +-- only writing it back out. function forgeMove(index, x, y) - local entity = FORGE.game.entities[index] + local entry = forgeRoom().entities[index] - entity.x = math.floor(x) - entity.y = math.floor(y) + entry.x = math.floor(x) + entry.y = math.floor(y) FORGE.dirty = true forgeRefresh() end @@ -348,6 +917,18 @@ function forgeSave() end +-- The video a description plays over. Its disc layer's when it names one, the menu's otherwise. +function forgeVideo() + for _, layer in ipairs(FORGE.game.layers or {}) do + if (layer.kind == "disc") and layer.file then + return layer.file + end + end + + return "Singe/menuBackground.mkv" +end + + -- Everything a released game needs, in a directory of its own: the compiled script, the runtime -- it loads, its games.dat, and the description it was built from so it can be opened again. -- @@ -412,28 +993,42 @@ function forgeExport(folder, name) end --- The video a description plays over. Its own when it names one, the menu's otherwise. -function forgeVideo() - return FORGE.game.video or "Singe/menuBackground.mkv" -end - - -- Compiles what is on screen and hands back the path, so the caller can dofile it and play. The --- runtime goes beside it, because that is where the compiled game looks for it. -function forgeBuild(outputFile) +-- runtime goes beside it, because that is where the compiled game looks for it. What the checker +-- has to say goes to the console and the panel. +function forgeBuild(outputFile, fromRoom) + local problems = authorCheck(FORGE.game) + local game = FORGE.game + FORGE.game.source = FORGE.path + -- Playing from here: a copy with the current room first, so the game begins in it. + if fromRoom and (fromRoom > 1) and FORGE.game.rooms[fromRoom] then + game = {} + for key, value in pairs(FORGE.game) do + game[key] = value + end + game.rooms = {} + for index = 0, #FORGE.game.rooms - 1 do + game.rooms[index + 1] = FORGE.game.rooms[((fromRoom - 1 + index) % #FORGE.game.rooms) + 1] + end + end local file = assert(io.open(outputFile, "w")) - file:write(authorCompile(FORGE.game)) + file:write(authorCompile(game)) file:close() authorCopy(AUTHOR_RUNTIME, (string.match(outputFile, "^(.*[/\\])") or "") .. "Author.singe") - FORGE.message = "built" + for _, problem in ipairs(problems) do + debugPrint("Forge: " .. problem) + end + FORGE.message = (#problems == 0) and "built" or ("built with " .. #problems .. " problems: " .. problems[1]) forgeRefresh() return outputFile end +-- ===== The pointer on the canvas ============================================================== + function forgePress(x, y) -- Whatever is under the chrome belongs to it, wherever it has been dragged to. The GUI has -- already had its chance at this click; checking again is belt and braces for the case where @@ -448,17 +1043,58 @@ function forgePress(x, y) if forgeOverPanel(x) then return end + -- A polygon being drawn takes every press as a corner. + if FORGE.drawing then + FORGE.drawing.points[#FORGE.drawing.points + 1] = math.floor(x) + FORGE.drawing.points[#FORGE.drawing.points + 1] = math.floor(y) + FORGE.message = (#FORGE.drawing.points / 2) .. " corners; V or ENTER closes it, ESC drops it" + forgeRefresh() + return + end + -- In the tracks, a press on the selected track's box picks the box up: the key at the cursor, + -- made if there is none. + if FORGE.mode == "tracks" then + local box = forgeCursorBox() + + if box and collidePointRect(x, y, box.x, box.y, box.w, box.h) then + forgeRemember() + FORGE.held = { key = forgeKeyAtCursor(true) } + FORGE.grabX = x - box.x + FORGE.grabY = y - box.y + forgeRefresh() + end + return + end + -- A gizmo handle first: it moves or turns the selected entity, whatever lies under it. + local handle = forgeGizmoAt(x, y) + + if handle then + forgeGizmoHold(handle, x, y) + return + end + if FORGE.scene then + FORGE.held = forgePick3D(x, y) + if FORGE.held ~= nil then + local entry = forgeRoom().entities[FORGE.held] + local gx, gz = forgeGroundPoint(x, y, entry.y or 0) + + forgeRemember() + FORGE.grabX = gx and (gx - (entry.x or 0)) or 0 + FORGE.grabY = gz and (gz - (entry.z or 0)) or 0 + FORGE.mode = "entities" + forgeSelect(FORGE.held) + end + return + end FORGE.held = forgePick(x, y) if FORGE.held ~= nil then - local entity = FORGE.game.entities[FORGE.held] + local entry = forgeRoom().entities[FORGE.held] -- One undo step for the whole drag, taken before it starts. forgeRemember() - FORGE.grabX = x - entity.x - FORGE.grabY = y - entity.y - if FORGE.mode ~= "entities" then - FORGE.mode = "entities" - end + FORGE.grabX = x - entry.x + FORGE.grabY = y - entry.y + FORGE.mode = "entities" forgeSelect(FORGE.held) end end @@ -490,8 +1126,8 @@ end -- The tab that drags the panel, in overlay coordinates. It is deliberately drawn on the *canvas*, -- just outside the panel, rather than being an element in the document: a press on the canvas is -- the one pointer path this editor has been shown to receive reliably, whereas whether a document --- gets the press at all depends on the GUI routing in _guiPointer, which the spike in PLAN section --- 58 found silent under a window manager. Drawing the tab ourselves needs none of that. +-- gets the press at all depends on the GUI routing in _guiPointer, which the spike in FORGE.md +-- section 1 found silent under a window manager. Drawing the tab ourselves needs none of that. function forgeTab() return FORGE.panelX + PANEL_W, TAB_Y, TAB_W, TAB_H end @@ -507,52 +1143,443 @@ end function forgeDrag(x, y) if FORGE.panelDrag ~= nil then forgePanelTo(x - FORGE.panelDrag) + elseif (type(FORGE.held) == "table") and FORGE.held.gizmo then + -- How far along the arm the pointer has gone, as a fraction of the arm, is how far along + -- the axis the entity goes. + local held = FORGE.held + local reach = held.dx * held.dx + held.dy * held.dy + local entry = forgeRoom().entities[FORGE.selected] + + if (held.gizmo == "ry") or (held.gizmo == "rz") then + -- The pointer's angle round the entity on screen, from where the press was, turns it: + -- with the screen's y downward, clockwise on the canvas is a positive rz, while ry + -- turns the other way seen from above. + local angle = math.atan(y - held.sy, x - held.sx) + local turn = math.deg(angle - held.angle) + + entry[held.gizmo] = math.floor(held.from + ((held.gizmo == "rz") and turn or -turn) + 0.5) % 360 + if entry[held.gizmo] == 0 then + entry[held.gizmo] = nil + end + FORGE.dirty = true + forgeRefresh() + elseif (reach > 0) and entry then + local t = ((x - held.x) * held.dx + (y - held.y) * held.dy) / reach + + entry[held.gizmo] = math.floor((held.from + t * GIZMO_LEN) * 10 + 0.5) / 10 + FORGE.dirty = true + forgeRefresh() + end + elseif type(FORGE.held) == "table" then + FORGE.held.key.x = math.floor(x - FORGE.grabX) + FORGE.held.key.y = math.floor(y - FORGE.grabY) + FORGE.dirty = true + forgeRefresh() + elseif (FORGE.held ~= nil) and FORGE.scene then + local entry = forgeRoom().entities[FORGE.held] + local gx, gz = forgeGroundPoint(x, y, entry.y or 0) + + if gx then + forgeMove3D(FORGE.held, gx - FORGE.grabX, entry.y or 0, gz - FORGE.grabY) + end elseif FORGE.held ~= nil then forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY) end end +-- Moves an entity in the scene, to a tenth of a unit. +function forgeMove3D(index, x, y, z) + local entry = forgeRoom().entities[index] + + entry.x = math.floor(x * 10 + 0.5) / 10 + entry.y = math.floor(y * 10 + 0.5) / 10 + entry.z = math.floor(z * 10 + 0.5) / 10 + FORGE.dirty = true + forgeRefresh() +end + + +-- ===== The 3D preview ========================================================================= +-- +-- A room in a game with a scene3d layer is shown as the scene itself: the room's entities are +-- built from their kinds' looks, exactly as the runtime builds them, under an editor camera that +-- orbits whatever is selected. Nothing steps -- no behaviours, no physics -- so it is the +-- description that is on screen, not a running game. + +function forgeIs3D() + for _, layer in ipairs(FORGE.game.layers or {}) do + if layer.kind == "scene3d" then + return layer + end + end + + return nil +end + + +-- The entities as a string, so the preview is rebuilt only when what they are changes; where +-- they are is synced every frame instead. +local function sceneStamp() + local parts = {} + + for _, entry in ipairs(forgeRoom().entities) do + local kind = FORGE.game.kinds[entry.kind] or {} + local look = kind.look or {} + + parts[#parts + 1] = tostring(entry.kind) .. ":" .. tostring(look.kind) .. ":" .. tostring(look.file) .. ":" .. tostring(look.shape) .. ":" .. tostring(look.w) .. ":" .. tostring(look.h) .. ":" .. tostring(look.d) .. ":" .. tostring(look.scale) .. ":" .. tostring(look.r) .. tostring(look.g) .. tostring(look.b) + end + + return table.concat(parts, "|") .. "#" .. FORGE.room +end + + +function forgeSceneClear() + if FORGE.scene == nil then + return + end + for _, node in pairs(FORGE.scene.nodes) do + nodeDelete(node) + end + FORGE.scene.nodes = {} + FORGE.scene.stamp = nil +end + + +function forgeSceneBegin() + local layer = forgeIs3D() + + if layer == nil then + return + end + if not sceneBegun then + AUTHOR.layers.scene3d.begin(layer) + sceneBegun = true + end + FORGE.scene = { nodes = {}, camera = nodeNew(), yaw = 30, pitch = 25, distance = 12, stamp = nil } + cameraSet(FORGE.scene.camera) + forgeSceneSync() +end + + +-- Builds what is missing and moves what is there. Called every frame the preview is drawn. +function forgeSceneSync() + local scene = FORGE.scene + local room = forgeRoom() + + if scene == nil then + return + end + if scene.stamp ~= sceneStamp() then + forgeSceneClear() + for index, entry in ipairs(room.entities) do + local kind = FORGE.game.kinds[entry.kind] or {} + local look = kind.look or { kind = "none" } + local instance = { node = nodeNew(), look = look, vars = { text = (entry.vars and entry.vars.text) or look.text }, nodes = {}, preview = true } + local maker = AUTHOR.looks[look.kind] + + if maker and maker.load and (look.kind ~= "sprite") and (look.kind ~= "text") then + local ok, err = pcall(maker.load, instance) + + if not ok then + debugPrint("Forge: " .. tostring(entry.id) .. " could not be shown: " .. tostring(err)) + end + end + scene.nodes[index] = instance.node + end + scene.stamp = sceneStamp() + end + for index, entry in ipairs(room.entities) do + if scene.nodes[index] then + nodeSetPosition(scene.nodes[index], entry.x or 0, entry.y or 0, entry.z or 0) + nodeSetRotation(scene.nodes[index], entry.rx or 0, entry.ry or 0, entry.rz or 0) + nodeSetScale(scene.nodes[index], entry.scale or 1) + end + end + forgeSceneCamera() +end + + +-- What the editor camera looks at: the selected entity, or the room's middle. +local function sceneFocus() + local entry = forgeRoom().entities[FORGE.selected] + + if entry then + return entry.x or 0, entry.y or 0, entry.z or 0 + end + + return 0, 0, 0 +end + + +function forgeSceneCamera() + local scene = FORGE.scene + local fx, fy, fz = sceneFocus() + local yaw = math.rad(scene.yaw) + local pitch = math.rad(scene.pitch) + + nodeSetPosition(scene.camera, fx + math.sin(yaw) * math.cos(pitch) * scene.distance, fy + math.sin(pitch) * scene.distance, fz + math.cos(yaw) * math.cos(pitch) * scene.distance) + nodeLookAt(scene.camera, fx, fy, fz) +end + + +function forgeOrbit(yawBy, pitchBy, zoomBy) + local scene = FORGE.scene + + scene.yaw = scene.yaw + yawBy + scene.pitch = math.max(-85, math.min(85, scene.pitch + pitchBy)) + scene.distance = math.max(0.5, scene.distance * zoomBy) + forgeSceneCamera() +end + + +-- The gizmo: three arms from the selected entity along the world axes, each ending in a handle +-- that drags the entity along that axis alone. Arms are found by where their ends project. +function forgeGizmoHandles() + local entry = forgeRoom().entities[FORGE.selected] + + if entry == nil then + return {} + end + local x, y, z = entry.x or 0, entry.y or 0, entry.z or 0 + local sx, sy, _, inFront = sceneProject(x, y, z) + local handles = {} + + if not inFront then + return {} + end + for _, axis in ipairs({ { name = "x", dx = 1, dy = 0, dz = 0, r = 255, g = 90, b = 90 }, { name = "y", dx = 0, dy = 1, dz = 0, r = 90, g = 255, b = 90 }, { name = "z", dx = 0, dy = 0, dz = 1, r = 90, g = 140, b = 255 } }) do + local tx, ty, _, tipFront = sceneProject(x + axis.dx * GIZMO_LEN, y + axis.dy * GIZMO_LEN, z + axis.dz * GIZMO_LEN) + + if tipFront then + handles[#handles + 1] = { axis = axis.name, sx = sx, sy = sy, tx = tx, ty = ty, r = axis.r, g = axis.g, b = axis.b } + end + end + -- The turn handle: a fourth arm the way the entity faces (its -Z, turned by ry), dragged + -- round the entity to turn it about Y. + local yaw = math.rad(entry.ry or 0) + local fx, fz = -math.sin(yaw) * GIZMO_LEN * 0.7, -math.cos(yaw) * GIZMO_LEN * 0.7 + local tx, ty, _, tipFront = sceneProject(x + fx, y, z + fz) + + if tipFront then + handles[#handles + 1] = { axis = "ry", sx = sx, sy = sy, tx = tx, ty = ty, r = 255, g = 200, b = 60 } + end + + return handles +end + + +-- The gizmo handle under a screen point, if any: the scene's arms, or the 2D turn handle. +function forgeGizmoAt(x, y) + local handles = FORGE.scene and forgeGizmoHandles() or { forgeTurnHandle() } + + for _, handle in ipairs(handles) do + if math.sqrt((handle.tx - x) ^ 2 + (handle.ty - y) ^ 2) <= GIZMO_GRAB then + return handle + end + end + + return nil +end + + +-- Takes a gizmo handle: where the press was, along the arm and round the entity, is what a drag +-- is measured from. +function forgeGizmoHold(handle, x, y) + local entry = forgeRoom().entities[FORGE.selected] + + forgeRemember() + FORGE.held = { gizmo = handle.axis, from = entry[handle.axis] or 0, x = x, y = y, dx = handle.tx - handle.sx, dy = handle.ty - handle.sy, sx = handle.sx, sy = handle.sy, angle = math.atan(y - handle.sy, x - handle.sx) } + FORGE.message = ((handle.axis == "ry") or (handle.axis == "rz")) and "turning" or ("moving along " .. handle.axis) +end + + +-- An arm from the entity to its handle, the handle a small box named for its axis. +function forgeDrawHandle(handle) + if handle == nil then + return + end + colorForeground(handle.r, handle.g, handle.b, 255) + overlayLine(handle.sx, handle.sy, handle.tx, handle.ty) + overlayBox(handle.tx - 4, handle.ty - 4, handle.tx + 4, handle.ty + 4) + fontPrint(handle.tx + 6, handle.ty - 8, handle.axis) +end + + +-- The turn handle on the 2D canvas: an arm from the selected entity the way its rz points, past +-- its box, dragged round the entity to turn it. The counterpart of the scene's ry arm. +function forgeTurnHandle() + local entry = forgeRoom().entities[FORGE.selected] + + if (entry == nil) or FORGE.scene then + return nil + end + local _, _, w, h = forgeBounds(entry) + local reach = math.max(w, h) / 2 + TURN_REACH + local turn = math.rad(entry.rz or 0) + + return { axis = "rz", sx = entry.x, sy = entry.y, tx = entry.x + math.cos(turn) * reach, ty = entry.y + math.sin(turn) * reach, r = 255, g = 200, b = 60 } +end + + +-- The entity nearest a screen point, within reach, by where it projects. +function forgePick3D(x, y) + local best, bestFar = nil, PICK_REACH + + for index, entry in ipairs(forgeRoom().entities) do + local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0) + + if inFront then + local far = math.sqrt((sx - x) ^ 2 + (sy - y) ^ 2) + + if far < bestFar then + best, bestFar = index, far + end + end + end + + return best +end + + +-- Where the pointer's ray crosses the horizontal plane through a height, so a drag moves an +-- entity across the floor it stands on rather than toward the camera. +function forgeGroundPoint(x, y, height) + local x0, y0, z0 = sceneUnproject(x, y, 0) + local x1, y1, z1 = sceneUnproject(x, y, 10) + local dy = y1 - y0 + + if math.abs(dy) < 0.0001 then + return nil + end + local t = (height - y0) / dy + + if t < 0 then + return nil + end + + return x0 + (x1 - x0) * t, z0 + (z1 - z0) * t +end + + +-- ===== Drawing ================================================================================ + +local function fill(x, y, w, h) + for row = math.floor(y), math.floor(y + h) do + overlayLine(x, row, x + w, row) + end +end + + -- Draws the description. Note that this draws the *description*, not a running game: there are --- no nodes here and no physics, which is why an entity can be dragged through a wall. +-- no physics here, which is why an entity can be dragged through a wall. function forgeDraw(pointerX, pointerY) + local room = forgeRoom() + + if FORGE.scene then + forgeDraw3D(pointerX, pointerY) + return + end colorBackground(18, 18, 26, 255) overlayClear() - for index, entity in ipairs(FORGE.game.entities) do - local bx, by, bw, bh = forgeBounds(entity) - local look = entity.look or {} + if FORGE.video then + videoDraw(FORGE.video, 0, 0, overlayGetWidth(), overlayGetHeight()) + end + -- Everything is drawn, including whatever is under the chrome. The panel is opaque and + -- slides, so it covers rather than hides: drag it aside and what was beneath it is there, + -- in the right place, and can be picked up. Clipping was the earlier answer and it lied. + for index, entry in ipairs(room.entities) do + local bx, by, bw, bh = forgeBounds(entry) + local look = forgeLook(entry) local sprite = (look.kind == "sprite") and spriteFor(look.file or "") or nil - -- Everything is drawn, including whatever is under the chrome. The panel is opaque and - -- slides, so it covers rather than hides: drag it aside and what was beneath it is there, - -- in the right place, and can be picked up. Clipping was the earlier answer and it lied. - colorForeground(look.r or 180, look.g or 180, look.b or 190, 255) if look.kind == "text" then + local text = (entry.vars and entry.vars.text) or look.text or "" + colorForeground(look.r or 235, look.g or 235, look.b or 245, 255) - if (look.text or "") ~= "" then - fontPrint(entity.x, entity.y, look.text) + if text ~= "" then + fontPrint(entry.x, entry.y, text) end overlayBox(bx, by, bx + bw, by + bh) elseif sprite ~= nil then - spriteDraw(sprite, bx, by) - else - for row = math.floor(by), math.floor(by + bh) do - overlayLine(bx, row, bx + bw, row) + -- Turned as the entity's rz says, on the shared image: the canvas has time to spare. + -- A sheet shows its first frame, turned the same way. + if (look.frames or 1) > 1 then + spriteRotateFrame(sheetFor(look.file, look.frames), entry.rz or 0, 1) + spriteDrawRotatedFrame(sheetFor(look.file, look.frames), entry.x, entry.y, entry.scale or 1) + else + spriteRotate(sprite, entry.rz or 0) + spriteScale(sprite, entry.scale or 1) + spriteDraw(sprite, entry.x, entry.y, true) end + elseif look.kind == "none" then + colorForeground(120, 120, 150, 255) + overlayBox(bx, by, bx + bw, by + bh) + overlayLine(bx, by, bx + bw, by + bh) + else + colorForeground(look.r or 180, look.g or 180, look.b or 190, 255) + fill(bx, by, bw, bh) end - if index == FORGE.selected then + if (FORGE.mode ~= "tracks") and (index == FORGE.selected) then colorForeground(255, 210, 70, 255) overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2) - overlayBox(entity.x - HANDLE, entity.y - HANDLE, entity.x + HANDLE, entity.y + HANDLE) + overlayBox(entry.x - HANDLE, entry.y - HANDLE, entry.x + HANDLE, entry.y + HANDLE) + forgeDrawHandle(forgeTurnHandle()) end end + -- The walk areas, as outlines, so a room's floor can be seen against its picture; hotspot + -- outlines the same; and the polygon being drawn, open at its last corner. + colorForeground(90, 200, 120, 255) + for _, polygon in ipairs(room.walk or {}) do + for at = 1, #polygon - 1, 2 do + local nx = polygon[at + 2] or polygon[1] + local ny = polygon[at + 3] or polygon[2] + + overlayLine(polygon[at], polygon[at + 1], nx, ny) + end + end + colorForeground(200, 160, 90, 255) + for _, entry in ipairs(room.entities) do + local kind = FORGE.game.kinds[entry.kind] + local spot = kind and behaviourOf(kind, "hotspot") or nil + local polygon = spot and authorNumbers(spot.polygon) or nil + + for at = 1, (polygon and #polygon - 1 or 0), 2 do + overlayLine(polygon[at], polygon[at + 1], polygon[at + 2] or polygon[1], polygon[at + 3] or polygon[2]) + end + end + if FORGE.drawing then + local points = FORGE.drawing.points + + colorForeground(255, 230, 120, 255) + for at = 1, #points - 3, 2 do + overlayLine(points[at], points[at + 1], points[at + 2], points[at + 3]) + end + for at = 1, #points - 1, 2 do + overlayBox(points[at] - 3, points[at + 1] - 3, points[at] + 3, points[at + 1] + 3) + end + end + -- Every track's box at the cursor, the selected one brighter, and the strip along the bottom. + for index, track in ipairs(room.tracks) do + local box = authorTrackBoxAt(track, FORGE.cursor) + + if box then + if (FORGE.mode == "tracks") and (index == FORGE.track) then + colorForeground(255, 90, 90, 255) + else + colorForeground(150, 70, 70, 255) + end + overlayBox(box.x, box.y, box.x + box.w, box.y + box.h) + end + end + if FORGE.mode == "tracks" then + forgeDrawStrip() + end -- The panel's drag tab. local tx, ty, tw, th = forgeTab() colorForeground(70, 70, 96, 255) - for trow = ty, ty + th do - overlayLine(tx, trow, tx + tw, trow) - end + fill(tx, ty, tw, th) colorForeground(255, 207, 74, 255) overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16) overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) @@ -567,6 +1594,134 @@ function forgeDraw(pointerX, pointerY) end +-- The scene draws itself; the overlay carries the selection, the rail, the tab, and the chrome. +function forgeDraw3D(pointerX, pointerY) + local room = forgeRoom() + + forgeSceneSync() + overlayClear() + for index, entry in ipairs(room.entities) do + local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0) + + if inFront then + if (FORGE.mode ~= "tracks") and (index == FORGE.selected) then + colorForeground(255, 210, 70, 255) + overlayBox(sx - HANDLE - 2, sy - HANDLE - 2, sx + HANDLE + 2, sy + HANDLE + 2) + else + colorForeground(120, 120, 150, 255) + end + overlayBox(sx - HANDLE, sy - HANDLE, sx + HANDLE, sy + HANDLE) + fontPrint(sx + HANDLE + 3, sy - HANDLE, tostring(entry.id)) + end + end + -- The gizmo on the selected entity. + if FORGE.mode ~= "tracks" then + for _, handle in ipairs(forgeGizmoHandles()) do + forgeDrawHandle(handle) + end + end + -- A rail is drawn as its polyline, its stops marked; every track with points is shown, the + -- selected one brighter. + for index, track in ipairs(room.tracks) do + local points = track.points or {} + local bright = (FORGE.mode == "tracks") and (index == FORGE.track) + + for at = 1, #points - 1 do + local a, b = points[at], points[at + 1] + + lineDraw(a.x or 0, a.y or 0, a.z or 0, b.x or 0, b.y or 0, b.z or 0, bright and 255 or 120, bright and 90 or 60, bright and 90 or 60) + end + for at, point in ipairs(points) do + local sx, sy, _, inFront = sceneProject(point.x or 0, point.y or 0, point.z or 0) + + if inFront then + if bright and (at == FORGE.key) then + colorForeground(255, 210, 70, 255) + elseif point.stop then + colorForeground(255, 90, 90, 255) + else + colorForeground(150, 70, 70, 255) + end + overlayBox(sx - 4, sy - 4, sx + 4, sy + 4) + if point.stop then + fontPrint(sx + 6, sy - 6, tostring(point.stop)) + end + end + end + end + if FORGE.mode == "tracks" then + forgeDrawStrip() + end + local tx, ty, tw, th = forgeTab() + + colorForeground(70, 70, 96, 255) + fill(tx, ty, tw, th) + colorForeground(255, 207, 74, 255) + overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16) + overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) + overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36) + if pointerX ~= nil then + colorForeground(255, 220, 60, 255) + overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) + overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) + end + guiDraw(FORGE.gui) +end + + +-- The timeline: a ruler along the bottom with the selected track's keys as ticks and the cursor. +-- The ruler runs from the first key to the last, or over a screen's worth when there are none. +function forgeDrawStrip() + local track = forgeRoom().tracks[FORGE.track] + local left = FORGE.panelX + PANEL_W + TAB_W + STRIP_PAD + local right = overlayGetWidth() - STRIP_PAD + local top = overlayGetHeight() - STRIP_H + local first = 0 + local last = 100 + + if left > right - 40 then + left = STRIP_PAD + end + local keys = track and (track.boxes or track.points) or nil + + if keys and #keys > 0 then + first = math.huge + last = -math.huge + for _, key in ipairs(keys) do + first = math.min(first, key.at) + last = math.max(last, key.at) + end + if last == first then + last = first + 1 + end + end + local span = last - first + + first = math.min(first, FORGE.cursor) + last = math.max(last, FORGE.cursor) + span = math.max(last - first, 1) + colorForeground(30, 30, 44, 255) + fill(left - STRIP_PAD, top, right - left + STRIP_PAD * 2, STRIP_H) + colorForeground(110, 110, 140, 255) + overlayLine(left, top + STRIP_H / 2, right, top + STRIP_H / 2) + for at, key in ipairs(keys or {}) do + local x = left + (key.at - first) / span * (right - left) + + if at == FORGE.key then + colorForeground(255, 210, 70, 255) + else + colorForeground(255, 90, 90, 255) + end + overlayLine(x, top + 6, x, top + STRIP_H - 6) + end + local cx = left + (FORGE.cursor - first) / span * (right - left) + + colorForeground(255, 255, 255, 255) + overlayLine(cx, top + 2, cx, top + STRIP_H - 2) + fontPrint(left, top + 4, tostring(FORGE.cursor)) +end + + -- ===== Undo and redo ========================================================================== -- -- The description is small and plain data, so undo is a copy of the whole thing taken before @@ -574,21 +1729,6 @@ end -- itself, which is what keeps every later feature from having to. Redo is the same the other -- way: what undo took out is kept until something else changes. -local function copyOf(value) - local out - - if type(value) ~= "table" then - return value - end - out = {} - for key, item in pairs(value) do - out[key] = copyOf(item) - end - - return out -end - - -- Keeps a copy on one of the two stacks, oldest dropped once it is full. local function keep(stack) stack[#stack + 1] = copyOf(FORGE.game) @@ -612,14 +1752,23 @@ local function swapTo(copy, other, message) FORGE.game = copy FORGE.editing = nil -- Whatever was being typed referred to the description just replaced. FORGE.held = nil - FORGE.selected = FORGE.selected and math.min(FORGE.selected, #FORGE.game.entities) or nil + FORGE.room = math.max(1, math.min(FORGE.room, #FORGE.game.rooms)) + local room = forgeRoom() + + FORGE.selected = FORGE.selected and math.min(FORGE.selected, #room.entities) or nil if FORGE.selected == 0 then FORGE.selected = nil end + if FORGE.game.kinds[FORGE.kindName or ""] == nil then + FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] + end FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules)) FORGE.part = math.min(FORGE.part, #forgeParts(FORGE.game.rules[FORGE.rule] or {})) + FORGE.track = math.max(1, math.min(FORGE.track, #room.tracks)) + FORGE.key = math.min(FORGE.key, #((room.tracks[FORGE.track] or {}).boxes or {})) FORGE.dirty = true FORGE.message = message + loadVideo() forgeRefresh() end @@ -652,20 +1801,293 @@ function forgeRedo() end --- ===== Entities =============================================================================== +-- ===== Kinds ================================================================================== -- --- An entity is a position, a look and a list of behaviours. All of it is edited the same way a --- rule's parameters are: ENTER walks the fields, and each is typed. The fields come from the --- manifest -- a look's parameters, a behaviour's parameters -- so a new look or behaviour is --- editable the moment it is declared. +-- A kind is a look, behaviours, and vars. All of it is edited the way a rule's parameters are: +-- ENTER walks the fields, and each is typed. The fields come from the manifest -- a look's +-- parameters, a behaviour's parameters -- so a new look or behaviour is editable the moment it is +-- declared. --- A name no other entity has, from a stem. +-- A name no other kind has, from a stem. +local function uniqueKind(stem) + local n = 1 + + if FORGE.game.kinds[stem] == nil then + return stem + end + while FORGE.game.kinds[stem .. n] ~= nil do + n = n + 1 + end + + return stem .. n +end + + +function forgeKindAdd() + local name = uniqueKind("thing") + + forgeRemember() + FORGE.game.kinds[name] = { look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, vars = {}, behaviours = {} } + FORGE.kindName = name + FORGE.lastField = nil + FORGE.dirty = true + FORGE.message = "added kind " .. name + forgeRefresh() + + return name +end + + +function forgeKindDuplicate() + local kind = FORGE.game.kinds[FORGE.kindName or ""] + local name + + if kind == nil then + return nil + end + name = uniqueKind(FORGE.kindName) + forgeRemember() + FORGE.game.kinds[name] = copyOf(kind) + FORGE.kindName = name + FORGE.lastField = nil + FORGE.dirty = true + FORGE.message = "copied as " .. name + forgeRefresh() + + return name +end + + +-- A kind still placed somewhere cannot go: the entities would have nothing to be. +function forgeKindDelete() + local name = FORGE.kindName + + if (name == nil) or (FORGE.game.kinds[name] == nil) then + return false + end + for _, room in ipairs(FORGE.game.rooms) do + for _, entry in ipairs(room.entities) do + if entry.kind == name then + FORGE.message = name .. " is placed in " .. room.name .. "; delete those first" + forgeRefresh() + return false + end + end + end + forgeRemember() + FORGE.game.kinds[name] = nil + FORGE.kindName = sortedKeys(FORGE.game.kinds)[1] + FORGE.dirty = true + FORGE.message = "deleted kind " .. name + forgeRefresh() + + return true +end + + +-- Renaming a kind renames every entity of it and every rule that names it. +local function renameKind(from, to) + for _, room in ipairs(FORGE.game.rooms) do + for _, entry in ipairs(room.entities) do + if entry.kind == from then + entry.kind = to + end + end + end + for _, rule in ipairs(FORGE.game.rules) do + if rule.each == from then + rule.each = to + end + for key, kind in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do + if (kind == "kind") and (rule[key] == from) then + rule[key] = to + end + end + for _, list in ipairs({ rule.when or {}, rule.act or {} }) do + for _, item in ipairs(list) do + local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] + + for key, kind in pairs(entry and entry.params or {}) do + if (kind == "kind") and (item[key] == from) then + item[key] = to + end + end + end + end + end + for _, kind in pairs(FORGE.game.kinds) do + for _, b in ipairs(kind.behaviours or {}) do + local entry = AUTHOR.behaviours[b.kind] + + for key, type_ in pairs(entry and entry.params or {}) do + if (type_ == "kind") and (b[key] == from) then + b[key] = to + end + end + end + end +end + + +-- Fills in whatever a look or a behaviour declares and the table does not yet have, so a kind +-- that was just typed in has a value for every parameter and compiles at once. +local function fillDefaults(t, params) + for key, kind in pairs(params or {}) do + if (t[key] == nil) and (FORGE_DEFAULTS[kind] ~= nil) then + t[key] = FORGE_DEFAULTS[kind] + end + end +end + + +-- A kind's fields, in the order the panel shows them: its name, its look and what that takes, its +-- vars, then its behaviours and what each of those takes. A behaviour's parameter is named +-- behaviour.parameter, which is how it is typed as well. +function forgeKindFieldNames(kind) + local names = { "name", "look" } + local look = AUTHOR.looks[(kind.look or {}).kind] + + for _, key in ipairs(sortedKeys(look and look.params or kind.look)) do + if key ~= "kind" then + names[#names + 1] = "look." .. key + end + end + names[#names + 1] = "vars" + names[#names + 1] = "behaviours" + for _, b in ipairs(kind.behaviours or {}) do + local entry = AUTHOR.behaviours[b.kind] + + for _, key in ipairs(sortedKeys(entry and entry.params or b)) do + if key ~= "kind" then + names[#names + 1] = b.kind .. "." .. key + end + end + end + + return names +end + + +-- Vars as one line: name=value, name=value. +local function varsText(vars) + local parts = {} + + for _, key in ipairs(sortedKeys(vars)) do + parts[#parts + 1] = key .. "=" .. tostring(vars[key]) + end + + return table.concat(parts, ", ") +end + + +-- And back: a number is a number, true and false are themselves, anything else is text. +local function varsFrom(text) + local vars = {} + + for name, value in string.gmatch(tostring(text), "([%w_]+)%s*=%s*([^,]*)") do + value = value:gsub("%s+$", "") + if tonumber(value) then + vars[name] = tonumber(value) + elseif value == "true" then + vars[name] = true + elseif value == "false" then + vars[name] = false + else + vars[name] = value + end + end + + return vars +end + + +function forgeKindGet(kind, name) + local group, key = string.match(name, "^([^.]+)%.(.+)$") + + if name == "name" then + return FORGE.kindName + elseif name == "look" then + return (kind.look or {}).kind + elseif name == "vars" then + return varsText(kind.vars) + elseif name == "behaviours" then + local kinds = {} + + for _, b in ipairs(kind.behaviours or {}) do + kinds[#kinds + 1] = b.kind + end + return table.concat(kinds, ", ") + elseif group == "look" then + return (kind.look or {})[key] + elseif group ~= nil then + local b = behaviourOf(kind, group) + + return b and b[key] or nil + end + + return nil +end + + +function forgeKindSet(kind, name, value) + local group, key = string.match(name, "^([^.]+)%.(.+)$") + + kind.look = kind.look or {} + if name == "name" then + if (value ~= "") and (value ~= FORGE.kindName) and (FORGE.game.kinds[value] == nil) then + renameKind(FORGE.kindName, value) + FORGE.game.kinds[value] = kind + FORGE.game.kinds[FORGE.kindName] = nil + FORGE.kindName = value + end + elseif name == "look" then + if AUTHOR.looks[value] ~= nil then + kind.look.kind = value + fillDefaults(kind.look, AUTHOR.looks[value].params) + else + FORGE.message = "no look called " .. tostring(value) + end + elseif name == "vars" then + kind.vars = varsFrom(value) + elseif name == "behaviours" then + -- A comma separated list of behaviours. One already carried keeps its values; a new one + -- starts with defaults; one left out goes. + local list = {} + + for want in string.gmatch(tostring(value), "[^,%s]+") do + local b = behaviourOf(kind, want) or { kind = want } + + if AUTHOR.behaviours[want] ~= nil then + fillDefaults(b, AUTHOR.behaviours[want].params) + list[#list + 1] = b + else + FORGE.message = "no behaviour called " .. want + end + end + kind.behaviours = list + elseif group == "look" then + kind.look[key] = value + elseif group ~= nil then + local b = behaviourOf(kind, group) + + if b ~= nil then + b[key] = value + end + end + FORGE.dirty = true + forgeRefresh() +end + + +-- ===== Entities =============================================================================== + +-- A name no entity in the room has, from a stem. local function uniqueId(stem) local taken = {} local n = 1 - for _, entity in ipairs(FORGE.game.entities) do - taken[entity.id] = true + for _, entry in ipairs(forgeRoom().entities) do + taken[entry.id or ""] = true end if not taken[stem] then return stem @@ -678,141 +2100,111 @@ local function uniqueId(stem) end --- A new box in the middle of the canvas, clear of the panel, selected and ready to be moved. +-- A new entity of the selected kind in the middle of the canvas, clear of the panel, selected and +-- ready to be moved. With no kinds yet, one is made first. function forgeEntityAdd(x, y) - local entity = { - id = uniqueId("thing"), - x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), - y = math.floor(y or (overlayGetHeight() / 2)), - look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, - behaviours = {} - } + local room = forgeRoom() + local kind = FORGE.kindName + if (kind == nil) or (FORGE.game.kinds[kind] == nil) then + kind = forgeKindAdd() + end forgeRemember() - FORGE.game.entities[#FORGE.game.entities + 1] = entity - FORGE.dirty = true - FORGE.message = "added " .. entity.id - forgeSelect(#FORGE.game.entities) + room.entities[#room.entities + 1] = { + kind = kind, + id = uniqueId(kind), + x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), + y = math.floor(y or (overlayGetHeight() / 2)) + } + FORGE.dirty = true + FORGE.message = "added " .. room.entities[#room.entities].id + forgeSelect(#room.entities) - return #FORGE.game.entities + return #room.entities end -- A copy of the selected entity, a little to one side so the two can be told apart. function forgeEntityDuplicate() - local entity = FORGE.game.entities[FORGE.selected] + local room = forgeRoom() + local entry = room.entities[FORGE.selected] local twin - if entity == nil then + if entry == nil then return nil end forgeRemember() - twin = copyOf(entity) - twin.id = uniqueId(entity.id) - twin.x = entity.x + 20 - twin.y = entity.y + 20 - FORGE.game.entities[#FORGE.game.entities + 1] = twin - FORGE.dirty = true - FORGE.message = "copied " .. entity.id .. " as " .. twin.id - forgeSelect(#FORGE.game.entities) + twin = copyOf(entry) + twin.id = uniqueId(entry.kind) + twin.x = entry.x + 20 + twin.y = entry.y + 20 + room.entities[#room.entities + 1] = twin + FORGE.dirty = true + FORGE.message = "copied " .. entry.id .. " as " .. twin.id + forgeSelect(#room.entities) - return #FORGE.game.entities + return #room.entities end function forgeEntityDelete() - local entity = FORGE.game.entities[FORGE.selected] + local room = forgeRoom() + local entry = room.entities[FORGE.selected] - if entity == nil then + if entry == nil then return false end forgeRemember() - table.remove(FORGE.game.entities, FORGE.selected) + table.remove(room.entities, FORGE.selected) FORGE.dirty = true - FORGE.message = "deleted " .. entity.id - if #FORGE.game.entities == 0 then + FORGE.message = "deleted " .. tostring(entry.id) + if #room.entities == 0 then forgeSelect(nil) else - forgeSelect(math.min(FORGE.selected, #FORGE.game.entities)) + forgeSelect(math.min(FORGE.selected, #room.entities)) end return true end --- The sorted keys of a parameter table, so fields always read in the same order. -local function sortedKeys(t) - local keys = {} +-- An entity's fields: what every entity has, then the vars its kind declares, overridable here. +function forgeEntityFieldNames(entry) + local names = { "id", "kind", "x", "y", "z" } - for key in pairs(t or {}) do - keys[#keys + 1] = key + if FORGE.scene then + names[#names + 1] = "rx" + names[#names + 1] = "ry" end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + names[#names + 1] = "rz" + names[#names + 1] = "scale" + local kind = FORGE.game.kinds[entry.kind] or {} - return keys -end - - --- The behaviour of a kind an entity carries, if any. -local function behaviourOf(entity, kind) - for _, b in ipairs(entity.behaviours or {}) do - if b.kind == kind then - return b - end - end - - return nil -end - - --- An entity's fields, in the order the panel shows them: what every entity has, then what its --- look takes, then its behaviours and what each of those takes. A behaviour's parameter is --- named kind.parameter, which is how it is typed as well. -function forgeEntityFieldNames(entity) - local names = { "id", "x", "y", "kind" } - local look = AUTHOR.looks[(entity.look or {}).kind] - - for _, key in ipairs(sortedKeys(look and look.params or entity.look)) do - if key ~= "kind" then - names[#names + 1] = key - end - end - names[#names + 1] = "behaviours" - for _, b in ipairs(entity.behaviours or {}) do - local kind = AUTHOR.behaviours[b.kind] - - for _, key in ipairs(sortedKeys(kind and kind.params or b)) do - if key ~= "kind" then - names[#names + 1] = b.kind .. "." .. key - end - end + for _, key in ipairs(sortedKeys(kind.vars)) do + names[#names + 1] = "vars." .. key end return names end -function forgeEntityGet(entity, name) - local kind, key = string.match(name, "^([^.]+)%.(.+)$") +function forgeEntityGet(entry, name) + local key = string.match(name, "^vars%.(.+)$") - if (name == "id") or (name == "x") or (name == "y") then - return entity[name] - elseif name == "kind" then - return (entity.look or {}).kind - elseif name == "behaviours" then - local kinds = {} + if key ~= nil then + local kind = FORGE.game.kinds[entry.kind] or {} - for _, b in ipairs(entity.behaviours or {}) do - kinds[#kinds + 1] = b.kind + if entry.vars and entry.vars[key] ~= nil then + return entry.vars[key] end - return table.concat(kinds, ", ") - elseif kind ~= nil then - local b = behaviourOf(entity, kind) - return b and b[key] or nil + return (kind.vars or {})[key] + end + if name == "scale" then + return entry.scale or 1 end - return (entity.look or {})[name] + return entry[name] end @@ -834,90 +2226,119 @@ local function renameEverywhere(from, to) end --- Fills in whatever a look or a behaviour declares and the table does not yet have, so a kind --- that was just typed in has a value for every parameter and compiles at once. -local function fillDefaults(t, params) - for key, kind in pairs(params or {}) do - if t[key] == nil then - t[key] = FORGE_DEFAULTS[kind] - end - end -end +function forgeEntitySet(entry, name, value) + local key = string.match(name, "^vars%.(.+)$") - -function forgeEntitySet(entity, name, value) - local kind, key = string.match(name, "^([^.]+)%.(.+)$") - - entity.look = entity.look or {} if name == "id" then - if (value ~= "") and (value ~= entity.id) then - renameEverywhere(entity.id, value) - entity.id = value - end - elseif (name == "x") or (name == "y") then - if type(value) == "number" then - entity[name] = math.floor(value) + if (value ~= "") and (value ~= entry.id) then + renameEverywhere(entry.id, value) + entry.id = value end elseif name == "kind" then - if AUTHOR.looks[value] ~= nil then - entity.look.kind = value - fillDefaults(entity.look, AUTHOR.looks[value].params) + if FORGE.game.kinds[value] ~= nil then + entry.kind = value else - FORGE.message = "no look called " .. tostring(value) + FORGE.message = "no kind called " .. tostring(value) end - elseif name == "behaviours" then - -- A comma separated list of kinds. One already carried keeps its values; a new one - -- starts with defaults; one left out goes. - local list = {} - - for want in string.gmatch(tostring(value), "[^,%s]+") do - local b = behaviourOf(entity, want) or { kind = want } - - if AUTHOR.behaviours[want] ~= nil then - fillDefaults(b, AUTHOR.behaviours[want].params) - list[#list + 1] = b - else - FORGE.message = "no behaviour called " .. want - end + elseif (name == "x") or (name == "y") or (name == "z") then + if type(value) == "number" then + entry[name] = FORGE.scene and (math.floor(value * 10 + 0.5) / 10) or math.floor(value) end - entity.behaviours = list - elseif kind ~= nil then - local b = behaviourOf(entity, kind) - - if b ~= nil then - b[key] = value + elseif (name == "rx") or (name == "ry") or (name == "rz") then + if type(value) == "number" then + entry[name] = (value ~= 0) and (math.floor(value + 0.5) % 360) or nil end - else - entity.look[name] = value + elseif name == "scale" then + if (type(value) == "number") and (value > 0) then + entry.scale = (value ~= 1) and value or nil + end + elseif key ~= nil then + entry.vars = entry.vars or {} + entry.vars[key] = value end FORGE.dirty = true forgeRefresh() end +-- ===== Rooms ================================================================================== + +function forgeRoomGo(index) + FORGE.room = math.max(1, math.min(index, #FORGE.game.rooms)) + FORGE.selected = nil + FORGE.track = 1 + FORGE.key = 0 + FORGE.held = nil + FORGE.lastField = nil + forgeRefresh() +end + + +function forgeRoomAdd() + local n = #FORGE.game.rooms + 1 + + forgeRemember() + FORGE.game.rooms[n] = { name = "room" .. n, entities = {}, tracks = {} } + FORGE.dirty = true + FORGE.message = "added room" .. n + forgeRoomGo(n) + + return n +end + + +-- ===== Drawing polygons ======================================================================= +-- +-- A walk area, or a hotspot's outline, is drawn on the canvas corner by corner: V starts one, +-- every press adds a corner, V or ENTER closes it. It goes to the selected entity's kind when +-- that kind has a hotspot behaviour, and to the room's walk areas otherwise. + +function forgePolygonBegin() + local entry = forgeRoom().entities[FORGE.selected] + local kind = entry and FORGE.game.kinds[entry.kind] or nil + local spot = kind and behaviourOf(kind, "hotspot") or nil + + FORGE.drawing = { points = {}, hotspot = spot } + FORGE.message = spot and ("drawing " .. entry.id .. "'s outline: press each corner") or "drawing a walk area: press each corner" + forgeRefresh() +end + + +function forgePolygonClose() + local drawing = FORGE.drawing + local room = forgeRoom() + + FORGE.drawing = nil + if #drawing.points < 6 then + FORGE.message = "a polygon needs three corners" + forgeRefresh() + return false + end + forgeRemember() + if drawing.hotspot then + local entry = forgeRoom().entities[FORGE.selected] + local kind = FORGE.game.kinds[entry.kind] + + behaviourOf(kind, "hotspot").polygon = drawing.points + FORGE.message = "outline drawn" + else + room.walk = room.walk or {} + room.walk[#room.walk + 1] = drawing.points + FORGE.message = "walk area " .. #room.walk .. " drawn" + end + FORGE.dirty = true + forgeRefresh() + + return true +end + + -- ===== The event sheet ======================================================================== -- -- Rules are where someone with little programming skill actually spends their time, so this is the --- half of the editor that matters. It is driven by keys rather than by the pointer: the bundled --- menu's document is driven by keys and the pad and has always worked that way, whereas whether a --- document receives the pointer is the open question recorded in PLAN section 58. A cabinet wants --- keys anyway. --- --- The vocabulary comes from AUTHOR, so a rule editor never needs changing when a condition or an --- action is added: it lists whatever the manifest declares. - -FORGE_DEFAULTS = { - number = 0, - string = "", - boolean = true, - entity = "", - scancode = "SPACE", - switch = "SWITCH_BUTTON1", - expression = '""', - lua = "-- your Lua here", - file = "" -} - +-- half of the editor that matters. The vocabulary comes from AUTHOR, so a rule editor never needs +-- changing when a condition, an action, or an event is added: it lists whatever the manifest +-- declares. -- Every condition and action of a rule, flattened, so one index walks the whole thing. function forgeParts(rule) @@ -926,6 +2347,9 @@ function forgeParts(rule) for _, item in ipairs(rule.when or {}) do parts[#parts + 1] = { kind = "when", item = item } end + for _, item in ipairs((rule.when or {}).any or {}) do + parts[#parts + 1] = { kind = "any", item = item } + end for _, item in ipairs(rule.act or {}) do parts[#parts + 1] = { kind = "act", item = item } end @@ -944,9 +2368,9 @@ function forgeMakePart(set, name) return nil end for key, kind in pairs(entry.params or {}) do - if kind == "entity" and #FORGE.game.entities > 0 then - item[key] = FORGE.game.entities[FORGE.selected or 1].id - else + if (kind == "kind") then + item[key] = FORGE.kindName or "" + elseif FORGE_DEFAULTS[kind] ~= nil then item[key] = FORGE_DEFAULTS[kind] end end @@ -955,30 +2379,36 @@ function forgeMakePart(set, name) end +-- Adds a condition ("when"), a condition in the rule's any group ("any"), or an action ("act"). function forgeRuleAdd(kindName, name) local rule = FORGE.game.rules[FORGE.rule] - local set = (kindName == "when") and AUTHOR.conditions or AUTHOR.actions + local set = (kindName == "act") and AUTHOR.actions or AUTHOR.conditions local item = forgeMakePart(set, name) + local list if (rule == nil) or (item == nil) then FORGE.message = "no such " .. kindName return false end - local parts - forgeRemember() - rule[kindName] = rule[kindName] or {} - rule[kindName][#rule[kindName] + 1] = item - FORGE.dirty = true - FORGE.lastField = nil - FORGE.message = "added " .. name + rule.when = rule.when or {} + rule.act = rule.act or {} + if kindName == "any" then + rule.when.any = rule.when.any or {} + list = rule.when.any + else + list = rule[kindName] + end + list[#list + 1] = item + FORGE.dirty = true + FORGE.lastField = nil + FORGE.message = "added " .. name -- Select what was just added, so its parameters can be set straight away. Conditions come -- before actions in the flattened list, so adding one shifts every action along: the new -- part is found by identity rather than by assuming it went on the end. - parts = forgeParts(rule) - for at = 1, #parts do - if parts[at].item == item then + for at, part in ipairs(forgeParts(rule)) do + if part.item == item then FORGE.part = at end end @@ -998,7 +2428,11 @@ function forgePartDelete() return false end forgeRemember() - list = rule[part.kind] + if part.kind == "any" then + list = rule.when.any + else + list = rule[part.kind] + end for i = 1, #list do if list[i] == part.item then table.remove(list, i) @@ -1034,12 +2468,12 @@ end function forgeRuleNew(note) forgeRemember() - FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", when = {}, act = {} } + FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", on = "frame", when = {}, act = {} } FORGE.rule = #FORGE.game.rules FORGE.part = 0 FORGE.lastField = nil FORGE.dirty = true - FORGE.message = "rule added" + FORGE.message = "rule added: E picks what it is on" forgeRefresh() return FORGE.rule @@ -1082,6 +2516,34 @@ function forgeRuleMove(by) end +-- Sets what a rule is triggered by. The old event's filter values go, since they mean nothing to +-- the new one, and the new one's start empty. +function forgeRuleOn(name) + local rule = FORGE.game.rules[FORGE.rule] + + if (rule == nil) or (AUTHOR.events[name] == nil) then + return false + end + forgeRemember() + for key in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do + rule[key] = nil + end + rule.on = name + for key, kind in pairs(AUTHOR.events[name].filter) do + if kind == "kind" then + rule[key] = FORGE.kindName + end + end + FORGE.part = 0 + FORGE.lastField = nil + FORGE.dirty = true + FORGE.message = "on " .. name + forgeRefresh() + + return true +end + + -- One condition or action as a line: its name, then its parameters in a fixed order so the same -- rule always reads the same way. function forgePartText(item) @@ -1095,6 +2557,24 @@ function forgePartText(item) end +-- A rule's own fields: its note, what it is on and that event's filters, each, room, and the +-- flags a sequence can carry. +function forgeRuleFieldNames(rule) + local names = { "note", "on" } + local event = AUTHOR.events[rule.on or "frame"] + + for _, key in ipairs(sortedKeys(event and event.filter or {})) do + names[#names + 1] = key + end + names[#names + 1] = "each" + names[#names + 1] = "room" + names[#names + 1] = "controls" + names[#names + 1] = "interrupt" + + return names +end + + function forgeMode(mode) FORGE.mode = mode FORGE.lastField = nil @@ -1103,48 +2583,242 @@ function forgeMode(mode) end --- ===== Pickers ================================================================================ +-- ===== Tracks and the timeline ================================================================ -- --- Choosing a condition, an action, a look or a behaviour is choosing from the manifest, and the --- manifest carries a line of help for each. The list takes the panel over while it is up. +-- 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. --- Opens a list. set is a manifest table (AUTHOR.conditions and its kind); choose is called with --- the name picked. -function forgePickBegin(title, set, choose) - local items = {} +function forgeTrackNew(name) + local room = forgeRoom() - for _, name in ipairs(sortedKeys(set)) do - items[#items + 1] = { name = name, help = set[name].help } + forgeRemember() + if FORGE.scene then + room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "time", points = {} } + else + room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "frame", boxes = {} } end - if #items == 0 then - FORGE.message = "nothing to choose from" - forgeRefresh() + FORGE.track = #room.tracks + FORGE.key = 0 + FORGE.dirty = true + FORGE.message = "track added: K adds a key at the cursor" + forgeRefresh() + + return FORGE.track +end + + +function forgeTrackDelete() + local room = forgeRoom() + + if room.tracks[FORGE.track] == nil then return false end - FORGE.picker = { title = title, items = items, at = 1, choose = choose } + forgeRemember() + table.remove(room.tracks, FORGE.track) + FORGE.track = math.max(1, math.min(FORGE.track, #room.tracks)) + FORGE.key = 0 + FORGE.dirty = true + FORGE.message = "track deleted" forgeRefresh() return true end +-- A track's keys, whichever kind it holds: boxes over a picture, or points along a rail. +function forgeTrackKeys(track) + return track.boxes or track.points or {} +end + + +-- The selected track's box at the cursor, interpolated, or nil. +function forgeCursorBox() + local track = forgeRoom().tracks[FORGE.track] + + if track == nil then + return nil + end + + return authorTrackBoxAt(track, FORGE.cursor) +end + + +-- The key exactly at the cursor; made from the interpolated box, or the nearest key, or a default, +-- when asked to and there is none. +function forgeKeyAtCursor(make) + local track = forgeRoom().tracks[FORGE.track] + local box + + if track == nil then + return nil + end + for at, key in ipairs(track.boxes) do + if key.at == FORGE.cursor then + FORGE.key = at + return key + end + end + if not make then + return nil + end + box = forgeCursorBox() + if box == nil then + local nearest + + for _, key in ipairs(track.boxes) do + if (nearest == nil) or (math.abs(key.at - FORGE.cursor) < math.abs(nearest.at - FORGE.cursor)) then + nearest = key + end + end + box = nearest or { x = (FORGE.panelX + PANEL_W + overlayGetWidth()) / 2 - 30, y = overlayGetHeight() / 2 - 30, w = 60, h = 60 } + end + local key = { at = FORGE.cursor, x = math.floor(box.x), y = math.floor(box.y), w = math.floor(box.w), h = math.floor(box.h) } + + track.boxes[#track.boxes + 1] = key + table.sort(track.boxes, function(a, b) return a.at < b.at end) + for at, item in ipairs(track.boxes) do + if item == key then + FORGE.key = at + end + end + FORGE.dirty = true + + return key +end + + +function forgeKeyAdd() + local track = forgeRoom().tracks[FORGE.track] + + if track == nil then + return false + end + forgeRemember() + if track.points then + -- A rail point is put where the editor camera is, looking where it looks: stand where the + -- player should and press K. + local cx, cy, cz = nodeGetPosition(FORGE.scene.camera) + local fx, fy, fz = sceneFocus() + local point = { at = FORGE.cursor, x = math.floor(cx * 10 + 0.5) / 10, y = math.floor(cy * 10 + 0.5) / 10, z = math.floor(cz * 10 + 0.5) / 10, look = { fx, fy, fz } } + + track.points[#track.points + 1] = point + table.sort(track.points, function(a, b) return a.at < b.at end) + for at, item in ipairs(track.points) do + if item == point then + FORGE.key = at + end + end + FORGE.dirty = true + else + forgeKeyAtCursor(true) + end + FORGE.lastField = nil + FORGE.message = "key at " .. FORGE.cursor + forgeRefresh() + + return true +end + + +function forgeKeyDelete() + local track = forgeRoom().tracks[FORGE.track] + local keys = track and forgeTrackKeys(track) or nil + + if (keys == nil) or (keys[FORGE.key] == nil) then + return false + end + forgeRemember() + table.remove(keys, FORGE.key) + FORGE.key = math.min(FORGE.key, #keys) + FORGE.dirty = true + FORGE.message = "key deleted" + forgeRefresh() + + return true +end + + +-- Moves the cursor and shows the disc's frame there. +function forgeCursorTo(at) + FORGE.cursor = math.max(0, math.floor(at)) + if FORGE.video then + videoSeek(FORGE.video, FORGE.cursor) + end + forgeRefresh() +end + + +-- ===== Pickers ================================================================================ +-- +-- Choosing a condition, an action, an event, a look, or a behaviour is choosing from the manifest, +-- and the manifest carries a line of help for each. The list takes the panel over while it is up. + +-- Opens a list. set is a manifest table (AUTHOR.conditions and its kind), or a list of +-- { name, help } items; choose is called with the name picked. Typing narrows the list. +function forgePickBegin(title, set, choose) + local items = {} + + if set[1] ~= nil then + items = set + else + for _, name in ipairs(sortedKeys(set)) do + items[#items + 1] = { name = name, help = set[name].help } + end + end + if #items == 0 then + FORGE.message = "nothing to choose from" + forgeRefresh() + return false + end + FORGE.picker = { title = title, items = items, at = 1, choose = choose, filter = "" } + forgeRefresh() + + return true +end + + +-- The picker's items that match what has been typed. +function forgePickItems() + local picker = FORGE.picker + local shown = {} + + for _, item in ipairs(picker.items) do + if (picker.filter == "") or item.name:lower():find(picker.filter:lower(), 1, true) then + shown[#shown + 1] = item + end + end + + return shown +end + + -- A key while a list is up. Answers whether it was taken. -function forgePickKey(scancode) +function forgePickKey(scancode, keysym) local picker = FORGE.picker if picker == nil then return false end + local shown = forgePickItems() + if scancode == SCANCODE.DOWN.value then - picker.at = math.min(picker.at + 1, #picker.items) + picker.at = math.min(picker.at + 1, #shown) elseif scancode == SCANCODE.UP.value then picker.at = math.max(picker.at - 1, 1) elseif scancode == SCANCODE.RETURN.value then - FORGE.picker = nil - picker.choose(picker.items[picker.at].name) + if shown[picker.at] then + FORGE.picker = nil + picker.choose(shown[picker.at].name) + end elseif scancode == SCANCODE.ESCAPE.value then FORGE.picker = nil FORGE.message = "left it" + elseif scancode == SCANCODE.BACKSPACE.value then + picker.filter = string.sub(picker.filter, 1, -2) + picker.at = 1 + elseif keysym and (keysym >= 32) and (keysym < 127) then + picker.filter = picker.filter .. string.char(keysym) + picker.at = 1 else return true end @@ -1154,29 +2828,114 @@ function forgePickKey(scancode) end +-- The files under the game's directory a field could name, as picker items. +function forgeFileItems() + local found = {} + + local function walk(directory, prefix, depth) + -- lfs.dir hands back its iterator and the directory it walks; both go to the for. + local ok, iterator, handle = pcall(lfs.dir, directory) + + if not ok then + return + end + for name in iterator, handle do + if (name:sub(1, 1) ~= ".") and (name ~= "Singe") and (name ~= "data") then + local path = prefix .. name + local mode = lfs.attributes(directory .. "/" .. name, "mode") + + if (mode == "directory") and (depth < FILE_DEPTH) then + walk(directory .. "/" .. name, path .. "/", depth + 1) + elseif mode == "file" then + local extension = name:match("%.([%w]+)$") + + if extension and FILE_KINDS[extension:lower()] then + found[#found + 1] = { name = path, help = extension:lower() } + end + end + end + end + end + walk(".", "", 0) + table.sort(found, function(a, b) return a.name < b.name end) + + return found +end + + +-- Whether a field names a file, by the manifest's word for it. +function forgeFieldIsFile(name) + local group, key = string.match(name, "^([^.]+)%.(.+)$") + + if (name == "file") or (name == "document") or (name == "texture") or (name == "sky") then + return true + end + if FORGE.mode == "kinds" then + local kind = FORGE.game.kinds[FORGE.kindName or ""] + + if (group == "look") and kind and kind.look then + local look = AUTHOR.looks[kind.look.kind] + + return (look and look.params[key]) == "file" + elseif group then + local behaviour = AUTHOR.behaviours[group] + + return (behaviour and behaviour.params[key]) == "file" + end + elseif FORGE.mode == "rules" then + local part = forgeParts(FORGE.game.rules[FORGE.rule] or {})[FORGE.part] + + if part then + local entry = AUTHOR.conditions[part.item[1]] or AUTHOR.actions[part.item[1]] + + return (entry and entry.params[name]) == "file" + end + end + + return false +end + + -- ===== Keys =================================================================================== -- -- Point the engine's callback at this and the editor is usable without a pointer at all, which is -- how the bundled menu has always worked and what a cabinet needs. -- --- TAB entities or rules +-- TAB entities, kinds, rules, tracks -- UP / DOWN move within the list -- LEFT/RIGHT move the panel out of the way +-- PGUP / PGDN the previous or next room -- ENTER type a value for what is selected; again for its next value -- ESC put the value back; otherwise close the description --- A add an entity D duplicate it --- N new rule C / T add a condition / an action from the list --- [ / ] move the rule up / down the sheet --- DELETE delete the selected entity, rule, condition or action --- U / R undo / redo S save --- B build P play (Forge as a game only) +-- A / D add, duplicate (an entity, or a kind) DELETE delete what is selected +-- N new rule, new track, or (in the entities) new room +-- E pick what the rule is on C / O / T add a condition / an "any" condition / an action +-- [ / ] move the rule; in the tracks, leap the cursor +-- , / . step the cursor K a key at the cursor +-- U / R undo / redo S save +-- B build P play (Forge as a game only) +-- J / L, Y / H, I / K in a 3D room: orbit, tilt, and zoom the editor camera +-- V draw a walk area, or the selected hotspot's outline, corner by corner +-- In a picker, typing narrows the list; a field that names a file offers the game's files. +-- In the dialogues: A adds a dialogue, a node, or a choice at the level selected; T adds an +-- action to a choice. function forgeKey(keysym, scancode) local rules = FORGE.game.rules + local room = forgeRoom() -- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain - -- integers, so the comparison is against .value. Comparing against the table itself is always - -- false with a real keyboard; it only appeared to work because a test passed the table. - if forgePickKey(scancode) then + -- integers, so the comparison is against .value. + if forgePickKey(scancode, keysym) then + return + end + if FORGE.drawing then + if (scancode == SCANCODE.V.value) or (scancode == SCANCODE.RETURN.value) then + forgePolygonClose() + elseif scancode == SCANCODE.ESCAPE.value then + FORGE.drawing = nil + FORGE.message = "polygon dropped" + forgeRefresh() + end return end if FORGE.editing ~= nil then @@ -1200,16 +2959,40 @@ function forgeKey(keysym, scancode) end if scancode == SCANCODE.TAB.value then - forgeMode((FORGE.mode == "rules") and "entities" or "rules") + for at, mode in ipairs(MODES) do + if mode == FORGE.mode then + forgeMode(MODES[(at % #MODES) + 1]) + return + end + end + forgeMode("entities") elseif scancode == SCANCODE.LEFT.value then forgePanelTo(FORGE.panelX - 40) elseif scancode == SCANCODE.RIGHT.value then forgePanelTo(FORGE.panelX + 40) + elseif scancode == SCANCODE.PAGEUP.value then + forgeRoomGo(FORGE.room - 1) + elseif scancode == SCANCODE.PAGEDOWN.value then + forgeRoomGo(FORGE.room + 1) + + -- The editor camera, in a 3D room: J and L orbit, Y and H tilt, I and K close in and back off. + elseif FORGE.scene and (scancode == SCANCODE.J.value) then + forgeOrbit(-ORBIT_STEP, 0, 1) + elseif FORGE.scene and (scancode == SCANCODE.L.value) then + forgeOrbit(ORBIT_STEP, 0, 1) + elseif FORGE.scene and (scancode == SCANCODE.Y.value) then + forgeOrbit(0, ORBIT_STEP, 1) + elseif FORGE.scene and (scancode == SCANCODE.H.value) then + forgeOrbit(0, -ORBIT_STEP, 1) + elseif FORGE.scene and (scancode == SCANCODE.I.value) then + forgeOrbit(0, 0, 1 / ZOOM_STEP) + elseif FORGE.scene and (scancode == SCANCODE.K.value) and (FORGE.mode ~= "tracks") then + forgeOrbit(0, 0, ZOOM_STEP) elseif FORGE.mode == "entities" then if scancode == SCANCODE.DOWN.value then - forgeSelect(math.min((FORGE.selected or 0) + 1, #FORGE.game.entities)) + forgeSelect(math.min((FORGE.selected or 0) + 1, #room.entities)) elseif scancode == SCANCODE.UP.value then forgeSelect(math.max((FORGE.selected or 2) - 1, 1)) elseif scancode == SCANCODE.A.value then @@ -1218,9 +3001,38 @@ function forgeKey(keysym, scancode) forgeEntityDuplicate() elseif scancode == SCANCODE.DELETE.value then forgeEntityDelete() + elseif scancode == SCANCODE.N.value then + forgeRoomAdd() + elseif (scancode == SCANCODE.V.value) and not FORGE.scene then + forgePolygonBegin() end - else + elseif FORGE.mode == "kinds" then + local names = sortedKeys(FORGE.game.kinds) + local at = 0 + + for index, name in ipairs(names) do + if name == FORGE.kindName then + at = index + end + end + if scancode == SCANCODE.DOWN.value then + FORGE.kindName = names[math.min(at + 1, #names)] or FORGE.kindName + FORGE.lastField = nil + forgeRefresh() + elseif scancode == SCANCODE.UP.value then + FORGE.kindName = names[math.max(at - 1, 1)] or FORGE.kindName + FORGE.lastField = nil + forgeRefresh() + elseif scancode == SCANCODE.A.value then + forgeKindAdd() + elseif scancode == SCANCODE.D.value then + forgeKindDuplicate() + elseif scancode == SCANCODE.DELETE.value then + forgeKindDelete() + end + + elseif FORGE.mode == "rules" then -- In the rules, up and down walk the rule and its parts as one list: past the last part -- of a rule is the next rule, which is how it reads on screen. if scancode == SCANCODE.DOWN.value then @@ -1245,10 +3057,18 @@ function forgeKey(keysym, scancode) forgeRefresh() elseif scancode == SCANCODE.N.value then forgeRuleNew() + elseif scancode == SCANCODE.E.value then + if rules[FORGE.rule] ~= nil then + forgePickBegin("what the rule is on", AUTHOR.events, forgeRuleOn) + end elseif scancode == SCANCODE.C.value then if rules[FORGE.rule] ~= nil then forgePickBegin("a condition", AUTHOR.conditions, function(name) forgeRuleAdd("when", name) end) end + elseif scancode == SCANCODE.O.value then + if rules[FORGE.rule] ~= nil then + forgePickBegin("a condition, any of which", AUTHOR.conditions, function(name) forgeRuleAdd("any", name) end) + end elseif scancode == SCANCODE.T.value then if rules[FORGE.rule] ~= nil then forgePickBegin("an action", AUTHOR.actions, function(name) forgeRuleAdd("act", name) end) @@ -1264,6 +3084,65 @@ function forgeKey(keysym, scancode) forgeRuleDelete() end end + + elseif FORGE.mode == "dialogues" then + if scancode == SCANCODE.DOWN.value then + forgeDialogueStep(1) + elseif scancode == SCANCODE.UP.value then + forgeDialogueStep(-1) + elseif scancode == SCANCODE.A.value then + forgeDialogueAdd() + elseif scancode == SCANCODE.T.value then + forgePickBegin("an action for the choice", AUTHOR.actions, forgeDialogueAct) + elseif scancode == SCANCODE.DELETE.value then + forgeDialogueDelete() + end + + elseif FORGE.mode == "tracks" then + local track = room.tracks[FORGE.track] + local keys = track and forgeTrackKeys(track) or {} + + if scancode == SCANCODE.DOWN.value then + if track and (FORGE.key < #keys) then + FORGE.key = FORGE.key + 1 + forgeCursorTo(keys[FORGE.key].at) + elseif FORGE.track < #room.tracks then + FORGE.track = FORGE.track + 1 + FORGE.key = 0 + end + FORGE.lastField = nil + forgeRefresh() + elseif scancode == SCANCODE.UP.value then + if FORGE.key > 0 then + FORGE.key = FORGE.key - 1 + if FORGE.key > 0 then + forgeCursorTo(keys[FORGE.key].at) + end + elseif FORGE.track > 1 then + FORGE.track = FORGE.track - 1 + FORGE.key = #forgeTrackKeys(room.tracks[FORGE.track]) + end + FORGE.lastField = nil + forgeRefresh() + elseif scancode == SCANCODE.N.value then + forgeTrackNew() + elseif scancode == SCANCODE.K.value then + forgeKeyAdd() + elseif scancode == SCANCODE.COMMA.value then + forgeCursorTo(FORGE.cursor - FRAME_STEP) + elseif scancode == SCANCODE.PERIOD.value then + forgeCursorTo(FORGE.cursor + FRAME_STEP) + elseif scancode == SCANCODE.LEFTBRACKET.value then + forgeCursorTo(FORGE.cursor - FRAME_LEAP) + elseif scancode == SCANCODE.RIGHTBRACKET.value then + forgeCursorTo(FORGE.cursor + FRAME_LEAP) + elseif scancode == SCANCODE.DELETE.value then + if FORGE.key > 0 then + forgeKeyDelete() + else + forgeTrackDelete() + end + end end if scancode == SCANCODE.S.value then @@ -1285,8 +3164,8 @@ 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 and a condition are typed the same way; forgeFields says what --- the fields are and how to reach them. +-- back what was there. An entity, a kind, a rule, a key, and a condition are typed the same way; +-- forgeFields says what the fields are and how to reach them. -- The parameters of a condition or action, in the order the panel shows them, so typing walks them -- the same way. @@ -1307,6 +3186,8 @@ end -- What ENTER edits: the fields of whatever is selected, with a way to read and write each. nil -- when nothing is. function forgeFields() + local room = forgeRoom() + if FORGE.mode == "rules" then local rule = FORGE.game.rules[FORGE.rule] local part @@ -1316,10 +3197,30 @@ function forgeFields() end if FORGE.part == 0 then return { - names = { "note" }, - get = function(key) return rule[key] end, + names = forgeRuleFieldNames(rule), + get = function(key) + if key == "controls" then + return rule.controls ~= false + elseif key == "interrupt" then + return rule.interrupt == true + end + return rule[key] + end, set = function(key, value) - rule[key] = value + if key == "on" then + if AUTHOR.events[value] then + forgeRuleOn(value) + end + return + elseif key == "controls" then + rule.controls = (value ~= false) and nil or false + elseif key == "interrupt" then + rule.interrupt = (value == true) or nil + elseif (key == "each" or key == "room") and (value == "") then + rule[key] = nil + else + rule[key] = value + end FORGE.dirty = true forgeRefresh() end @@ -1334,21 +3235,174 @@ function forgeFields() get = function(key) return part.item[key] end, set = forgePartSet } - end - local entity = FORGE.game.entities[FORGE.selected] + elseif FORGE.mode == "kinds" then + local kind = FORGE.game.kinds[FORGE.kindName or ""] - if entity == nil then - return nil + if kind == nil then + return nil + end + return { + names = forgeKindFieldNames(kind), + get = function(key) return forgeKindGet(kind, key) end, + set = function(key, value) forgeKindSet(kind, key, value) end + } + elseif FORGE.mode == "dialogues" then + return forgeDialogueFields() + elseif FORGE.mode == "tracks" then + local track = room.tracks[FORGE.track] + + if track == nil then + return nil + end + if FORGE.key == 0 then + return { + names = { "name", "key" }, + get = function(key) return track[key] end, + set = function(key, value) + if (key ~= "key") or (value == "frame") or (value == "time") then + track[key] = value + end + FORGE.dirty = true + forgeRefresh() + end + } + end + local keys = forgeTrackKeys(track) + local box = keys[FORGE.key] + + return { + names = track.points and { "at", "x", "y", "z", "lookX", "lookY", "lookZ", "stop" } or { "at", "x", "y", "w", "h" }, + get = function(key) + if key:sub(1, 4) == "look" then + local look = box.look or {} + local axis = key:sub(5):lower() + + return look[axis] or look[({ x = 1, y = 2, z = 3 })[axis]] + end + return box[key] + end, + set = function(key, value) + if key:sub(1, 4) == "look" then + local axis = key:sub(5):lower() + + if type(value) == "number" then + box.look = box.look or { 0, 0, 0 } + if box.look.x ~= nil then + box.look[axis] = value + else + box.look[({ x = 1, y = 2, z = 3 })[axis]] = value + end + end + elseif key == "stop" then + box.stop = (value ~= "") and tostring(value) or nil + elseif type(value) == "number" then + box[key] = value + if key == "at" then + table.sort(keys, function(a, b) return a.at < b.at end) + for at, item in ipairs(keys) do + if item == box then + FORGE.key = at + end + end + end + end + FORGE.dirty = true + forgeRefresh() + end + } + end + local entry = room.entities[FORGE.selected] + + if entry == nil then + -- Nothing selected: the room itself. + return { + names = { "name", "reset", "depthSort", "walk", "navFrom", "scaleBy" }, + get = function(key) return forgeRoomGet(room, key) end, + set = function(key, value) forgeRoomSet(room, key, value) end + } end return { - names = forgeEntityFieldNames(entity), - get = function(key) return forgeEntityGet(entity, key) end, - set = function(key, value) forgeEntitySet(entity, key, value) end + names = forgeEntityFieldNames(entry), + get = function(key) return forgeEntityGet(entry, key) end, + set = function(key, value) forgeEntitySet(entry, key, value) end } end +-- A room's own fields, typed as text: walk areas as polygons of x,y pairs separated by +-- semicolons, navFrom as a list of entity ids, scaleBy as y:scale pairs. +function forgeRoomGet(room, key) + if key == "walk" then + local polygons = {} + + for _, polygon in ipairs(room.walk or {}) do + local pairs_ = {} + + for at = 1, #polygon - 1, 2 do + pairs_[#pairs_ + 1] = polygon[at] .. "," .. polygon[at + 1] + end + polygons[#polygons + 1] = table.concat(pairs_, " ") + end + return table.concat(polygons, "; ") + elseif key == "navFrom" then + return table.concat(room.navFrom or {}, ", ") + elseif key == "scaleBy" then + local parts = {} + + for _, item in ipairs(room.scaleBy or {}) do + parts[#parts + 1] = item.y .. ":" .. item.scale + end + return table.concat(parts, ", ") + elseif key == "reset" or key == "depthSort" then + return room[key] == true + end + + return room[key] +end + + +function forgeRoomSet(room, key, value) + if key == "walk" then + local polygons = {} + + for text in string.gmatch(tostring(value), "[^;]+") do + local polygon = {} + + for number in string.gmatch(text, "-?[%d.]+") do + polygon[#polygon + 1] = tonumber(number) + end + if #polygon >= 6 then + polygons[#polygons + 1] = polygon + end + end + room.walk = (#polygons > 0) and polygons or nil + elseif key == "navFrom" then + local ids = {} + + for id in string.gmatch(tostring(value), "[^,%s]+") do + ids[#ids + 1] = id + end + room.navFrom = (#ids > 0) and ids or nil + elseif key == "scaleBy" then + local list = {} + + for y, scale in string.gmatch(tostring(value), "(-?[%d.]+)%s*:%s*(-?[%d.]+)") do + list[#list + 1] = { y = tonumber(y), scale = tonumber(scale) } + end + room.scaleBy = (#list > 0) and list or nil + elseif key == "reset" or key == "depthSort" then + room[key] = (value == true) or nil + elseif key == "name" then + if (value ~= "") then + room.name = tostring(value) + end + end + FORGE.dirty = true + forgeRefresh() +end + + -- A typed value as the description should hold it. A number has to come back a number: "220" and -- 220 compile to different source, and a description that changed shape because a value was -- retyped would stop round-tripping. @@ -1425,6 +3479,18 @@ function forgeEditNext() at = (at % #fields.names) + 1 FORGE.lastField = fields.names[at] FORGE.editing = { key = fields.names[at], text = "", was = fields.get(fields.names[at]), set = fields.set } + -- A field that names a file offers the files under the game's directory; ESC on the list + -- leaves the field being typed instead. + if forgeFieldIsFile(fields.names[at]) then + local field = fields.names[at] + + forgePickBegin("a file for " .. field, forgeFileItems(), function(path) + fields.set(field, path) + FORGE.editing = nil + FORGE.message = field .. " = " .. path + end) + return true + end FORGE.message = "type a value for " .. fields.names[at] .. ", ENTER for the next, ESC to put it back" forgeRefresh() @@ -1463,20 +3529,28 @@ end -- on, which is the point; everything in it can be changed or deleted. function forgeTemplate() return { - title = "New game", - layers = { { kind = "world2d", gravity = 1500 } }, - entities = { - { id = "ground", x = 360, y = 440, look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, - behaviours = { { kind = "solid" } } }, - { id = "hero", x = 120, y = 380, look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, - behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, - { id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } + title = "New game", + layers = { { kind = "world2d", gravity = 1500 } }, + vars = { score = 0 }, + kinds = { + ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, + hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, + readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } }, - rules = { - { note = "Run left", when = { { "keyHeld", key = "LEFT" } }, act = { { "run", entity = "hero", direction = -1 } } }, - { note = "Run right", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } }, - { note = "Jump, with ground underfoot", when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, act = { { "jump", entity = "hero" } } }, - { note = "The readout, every frame", when = {}, act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } } + rooms = { + { name = "start", + entities = { + { kind = "ground", id = "ground", x = 360, y = 440 }, + { kind = "hero", id = "hero", x = 120, y = 380 }, + { kind = "readout", id = "readout", x = 12, y = 12 } + }, + tracks = {} } + }, + rules = { + { note = "Run left", on = "frame", when = { { "keyHeld", key = "LEFT" } }, act = { { "run", entity = "hero", direction = -1 } } }, + { note = "Run right", on = "frame", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } }, + { note = "Jump, with ground underfoot", on = "frame", when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, act = { { "jump", entity = "hero" } } }, + { note = "The readout, every frame", on = "frame", act = { { "setText", entity = "readout", text = '"score " .. score' } } } } } end @@ -1568,12 +3642,12 @@ 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 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) @@ -1627,9 +3701,7 @@ if not FORGE_LIBRARY then end if at == chooser.at then colorForeground(192, 60, 150, 255) - for row = y - 3, y + ROW_H - 6 do - overlayLine(LIST_X - 10, row, LIST_X + 400, row) - end + fill(LIST_X - 10, y - 3, 410, ROW_H - 3) colorForeground(255, 255, 255, 255) else colorForeground(230, 230, 238, 255) @@ -1642,7 +3714,7 @@ if not FORGE_LIBRARY then -- Saves, builds, and hands the game to the engine. Back here afterwards, on the same file. function forgePlay() - local built = forgeBuild(singeGetDataPath() .. "preview.singe") + local built = forgeBuild(singeGetDataPath() .. "preview.singe", FORGE.room) local note = io.open(LAST_FILE, "w") forgeSave() diff --git a/build-docs.sh b/build-docs.sh index 63eee70fa..6b29147ec 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -14,7 +14,7 @@ for doc in "$src" "$forge"; do exit 1 fi done -for tool in asciidoctor asciidoctor-pdf; do +for tool in asciidoctor asciidoctor-pdf lua5.4; do if ! command -v "$tool" > /dev/null; then echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2 exit 1 @@ -25,6 +25,8 @@ version=$(sed -n 's/^project(singe2 VERSION \([0-9.]*\).*/\1/p' "$here/CMakeList out=$here/.builddir mkdir -p "$out" +# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it. +lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc" asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html" asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf" asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html" diff --git a/cmake/Superbuild.cmake b/cmake/Superbuild.cmake index 613c05c42..8a58986c1 100644 --- a/cmake/Superbuild.cmake +++ b/cmake/Superbuild.cmake @@ -471,9 +471,12 @@ if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")) COMMAND ${CMAKE_COMMAND} -E rm -rf ${forgePack} ${forgeStage} COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets/Forge ${forgeStage}/Forge COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf + # The samples that need no art beyond what the engine ships, so the chooser has something to + # open on a fresh install. They come from the test set, which is the one copy of them. + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game ${forgeStage}/Forge/ # Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory. COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack} - DEPENDS singe ${forgeSources} ${forgeManual} + DEPENDS singe ${forgeSources} ${forgeManual} ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game COMMENT "Packing Forge into .builddir/Forge.game" VERBATIM ) diff --git a/docs/Forge.adoc b/docs/Forge.adoc index 034e7a000..cc685cd23 100644 --- a/docs/Forge.adoc +++ b/docs/Forge.adoc @@ -18,61 +18,70 @@ Forge is Singe's authoring tool: a way to make a game by describing it -- placing things, attaching behaviours, writing rules -- and having ordinary Singe Lua written for you. It is itself a Singe game and is distributed on its own, beside the engine rather than inside it. This document covers the -description format, the vocabulary, the compiler and the editor. The engine +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. +`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe Manual. == Describing a Game Instead of Writing One -A game can be written as a *description* -- a table of layers, entities 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 -- so a game built with it carries its own copy of -that runtime and stands entirely on its own. Nothing is interpreted at run time: the rules -become real Lua `if` statements, so a description costs nothing per frame on a -Raspberry Pi, and the game it produces can be opened, read and edited by hand -like any other. +A game can be written as a *description* -- a table of kinds, 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 -- +so a game built with it carries its own copy of that runtime and stands entirely +on its own. Nothing is interpreted at run time: the rules become real Lua +functions, so a description costs nothing per frame on a Raspberry Pi, and the +game it produces can be opened, read, and edited by hand like any other. -There is no notion of genre anywhere in it. A game declares which of the +There is no notion of genre anywhere in it. A game declares which of the engine's own layers it uses, and that is the only difference between a light -gun game, a platformer and a quick-time event over video. +gun game, a platformer, and a quick-time event over video. -=== The three nouns +=== The nouns -*Layers* are what the game draws through: `world2d` (physics in the XY plane, -drawn into the overlay), `overlay` (flat drawing over everything) and `disc` -(the video the game is played over). A game lists the ones it wants. +*Layers* are what the game draws through: `world2d` (physics in the XY plane, +drawn into the overlay), `scene3d` (the 3D scene, with a sun, a sky, and fog; +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. -*Entities* are things on a layer. Each has a position, a `look` (`box`, -`sprite` or `text`) and any number of behaviours. Every entity is a node, -whether or not the 3D scene is drawing, which is what lets a 2D game built this -way run on a machine with no GPU. +*Kinds* 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. -*Behaviours* are bundles over engine calls that already exist: `platformer` is -the character controller (`playerNew` and friends), `solid` is a -static body, `drift` moves at a constant velocity. Attaching one is a line in -the description rather than code. +*Rooms* are where things are. A room lists its `entities` -- a kind, 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 conditions and actions. Every rule is tested every frame, in the -order written, and all of a rule's conditions must hold for its actions to run. +*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 +`self` bound. All of a rule's conditions must hold for its actions to run; an +`any` group inside them is an OR. .A description, in full [source,lua] ---- return { - title = "One rule", + title = "One rule", layers = { { kind = "world2d", gravity = 1500 } }, - entities = { - { id = "ground", x = 360, y = 440, - look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, - behaviours = { { kind = "solid" } } }, - { id = "hero", x = 120, y = 380, - look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, - behaviours = { { kind = "platformer", speed = 210, jump = 620 } } } + vars = { score = 0 }, + kinds = { + ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, + behaviours = { { kind = "solid" } } }, + hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, + behaviours = { { kind = "platformer", speed = 210, jump = 620 } } } }, - rules = { + rooms = { + { name = "start", + entities = { { kind = "ground", id = "ground", x = 360, y = 440 }, + { kind = "hero", id = "hero", x = 120, y = 380 } } } + }, + rules = { { note = "Run right", + on = "frame", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } } } @@ -87,56 +96,375 @@ dofile("Forge/AuthorCompile.singe") dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe")) ---- -`testScripts/author/platformer.game` and `testScripts/author/qte.game` are -worked examples, and `testScripts/scene52.singe` and `scene53.singe` compile -and play them. +`testScripts/author/` holds the worked examples -- a platformer, a QTE over +video, a shoot-em-up, a light gun game over video, a rail shooter, a 3D +platformer, an adventure in 2D and in 3D, a racing game, a tower defence, +branching video, a first-person shooter, breakout, a maze, third-person +action, a space shooter, a rhythm game, and bowling, with two more for the +sprite, tile, MIDI, water, and soft-body pieces -- and +`testScripts/scene52.singe` onward compile and play them. -=== The vocabulary, and adding to it +=== Instances, vars, and states -Conditions and actions are not built into the compiler. Each is an entry in -the `AUTHOR` table declaring its parameters and the Lua it emits, so a new kind -of game is a set of entries rather than a new release. The conditions today -are `keyHeld`, `switchHeld`, `timeBetween`, `discBetween`, `onGround`, -`touching`, `below`, `flagSet` and `once`; the actions are `run`, `jump`, -`moveTo`, `setText`, `show`, `addScore`, `setFlag`, `discTo` and `lua`. +A kind'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 +with; `setState` changes it and `inState` tests it. -`once` deserves a word. Rules run every frame, so anything that should happen -a single time -- a door opening, a score awarded -- needs it: +Vars declared on the game -- `vars = { score = 0, lives = 3 }` -- belong to +no instance and are read by name: `score`, `lives`. `setVar` and `addVar` +with no entity change them, and `addScore` is `addVar` on `score`. + +Instances are made when a room is entered, by a `spawner` or `shooter` +behaviour, or by the `spawn` action, and go with `destroy`, with a `health` +that reaches zero, or with a `projectile` that leaves the picture. An +instance made at run time has an id of its own (`raider#7`); rules reach it as +`self` or `other`, or through `each`. + +=== Events + +A rule's `on` says what triggers it: + +[cols="1,3"] +|=== +| `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` +| `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` +| `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` +| `roomStart`, `roomEnd`, `gameOver` | the game's own moments +|=== + +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 +to one room. + +=== Expressions + +Anywhere a number goes, an expression may go instead, as a string: numbers, +strings, `+ - * / % ^ ..`, comparisons, `and`, `or`, `not`, and the names +below. Nothing else is allowed -- an expression is checked when the game is +built -- and the `lua` action is the way past it. + +[cols="1,2"] +|=== +| `self.health`, `other.x`, `event.frame` | the instance the rule is about, the other one, the event's own fields +| `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 +| `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 +|=== + +A `test` condition takes one: `{ "test", expr = "count('raider') == 0" }`. + +=== Sequences + +Some actions take time. `say` shows a line and waits for it; `wait` waits. +A rule with one of them runs as a coroutine, its actions in order, each waiting +its turn -- a cut-scene is the same shape as `addScore`. While it runs the +same rule does not start again for the same instance unless the rule says +`interrupt = true`, and `controls = false` on the rule takes the keys away for +its duration. + +=== Tracks + +A track is keys by disc frame (`key = "frame"`) or by time (`key = "time"`), +and a room may hold any number. A track of `boxes` is a hitbox that moves +with the picture: [source,lua] ---- -{ when = { { "touching", entity = "hero", other = "prize" }, - { "once", tag = "prize" } }, - act = { { "addScore", amount = 100 }, - { "show", entity = "prize", visible = false } } } +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 } } } +} ---- +Between keys the box is interpolated; outside them there is none. A kind 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. + +A track of `points` is a rail for a camera: positions by time, each looking +at a point (`look = { x, y, z }`) or at an entity by id, and any point may be +a `stop` that holds the camera there until a `pathNext` action sends it on -- +which is what House of the Dead does at every doorway. + +[source,lua] +---- +{ 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" } } } +---- + +=== Games in the scene + +A game with a `scene3d` layer places its kinds 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 +overlay over the scene, which is how a score readout works in both. + +The behaviours that live there: `character` is the engine's controller +moving by the `keys` vars relative to the camera; `camera` draws the scene +from the instance -- `fixed`, `follow` (an offset from a target), `orbit` +and `first` (the mouse looks), or `rail`; `seek` walks toward a target and +raises `arrived`; `animator` plays a clip per state and raises +`animationDone`; `target` gives a `gun` something to hit, with `zones` on +named bones so a shot says which part it landed on (`hitPart`); `health` +with `ragdoll` lets a model fall limp on death; `solid`, `body`, and +`trigger` are the physics. A gun in the scene casts a ray through the +pointer, so the same `hit` and `miss` rules serve a light gun over video and +a rail shooter. + +`testScripts/author/railshooter.game` is House of the Dead in a table: a +rail with two stops, zombies that spawn there, walk to the camera, and bite +on a timer unless they are killed, headshots through a zone on the head bone, +and the rail moving on when the stop is clear. + +=== Adventures: rooms, walking, hotspots, verbs, and talk + +A point-and-click adventure -- Sierra's or LucasArts' -- is rooms that +remember themselves, a character that walks where the player clicks, hotspots +a verb is used on, an inventory, dialogue, and cut-scenes. All of it is the +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 +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 +goes. A painted room may say `depthSort = true`, so what is lower on the +picture is drawn over what is higher, and `scaleBy = { { y = 300, scale = 0.6 +}, { y = 460, scale = 1 } }`, so a character further up the picture is drawn +smaller; looks with `anchor = "feet"` stand on their position rather than +being centred on it, which is what a walking character wants. + +*3D over a painting.* A room in the scene may be a picture on a flat `mesh` +(its `texture`, `unlit`) with 3D characters in front of it. Whatever stands +in the picture -- a pillar, a doorway, a table -- that a character has to walk +behind is a `mesh` look with `occluder = true`: an invisible box that hides +what is behind it while the picture shows through it. The editor's 3D +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, +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. + +*Verbs.* A game with `verbs = { "walk", "look", "take", "use", "open", "talk" +}` builds a sentence line from the current verb, the hotspot under the +pointer, and the item in hand (`sentence`, `hover`, `verb`, and `item` are +game vars, so a text look or a HUD element can show them). A click on a +hotspot raises `verb` with `verb`, `target`, and `item`, and **the most +specific rule wins**: `verb = "use", item = "key", target = "door"` beats +`verb = "use", target = "door"` beats `verb = "use"` beats a rule that names +nothing, which is the "That doesn't work" every adventure needs. A rule with +conditions ranks above one without among the same parameters, so "open the +door while the guard objects" and "open the door" can share their parameters +and differ in a `test`. `setVerb`, `nextVerb`, and `useItem` choose; the +first verb in the list is what a click on the floor means, and the `pressed` +event carries the pointer's `x` and `y` for a `walkToPointer` rule. + +*The parser.* A `parser` layer takes typing at a prompt: `words = { look = +{ "look", "examine", "l" }, door = { "door", "gate" } }` says what the game +knows, and a typed line raises `said` with its `verb`, `noun`, and `second` +as the words table knows them -- or `event.unknown`, the first word it did +not -- with the same most-specific-wins rule. A game may offer both a verb +line and a parser; the rules do not care which was used. + +*Inventory.* `give` and `take` keep a list in the `inventory` var; +`has("key")` tests it in an expression and `has` as a condition. + +*Dialogue.* The game's `dialogues` are trees: + +[source,lua] +---- +dialogues = { + guard = { + start = "hello", + nodes = { + hello = { who = "Guard", text = "Nobody passes.", + choices = { { text = "Why not?", next = "why" }, { text = "Fine." } } }, + why = { who = "Guard", text = "The door is locked.", + choices = { { text = "I have a key.", when = 'has("key")', + act = { { "setVar", name = "allowed", value = true } }, next = "ok" }, + { text = "I see." } } }, + ok = { who = "Guard", text = "Then go ahead." } + } + } +} +---- + +`talk` runs one and waits for it: each node's line is said, its choices +(those whose `when` holds, and not those already chosen once with `once`) are +offered and picked with the number keys or a click, the choice's `act` runs, +and `next` names the node after. With a HUD, the choices go in the element +called `choices`. + +*Cut-scenes and rooms.* A rule with `controls = false` takes the keys away +while it runs; `fade` (out, then back with `out = false`) and `goTo` change +the picture and the room, and `goTo` with `at = "arrival"` puts the carried +entity where the entity of that id stands in the new room, so the same rule +serves a painted room and a modelled one. Sequences survive a room change. + +*Saving.* `saveGame` keeps the whole game -- the room, every visited room's +instances and their vars, the game's vars, the inventory -- in a numbered +slot, and `loadGame` brings it back. A save taken while a sequence runs +records the world as it stands; the sequence does not resume. `die` is the +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. + +=== Vehicles, crowds, turrets, and physics toys + +`vehicle` puts a kind 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 +`points` on its own, steering toward the next and easing off in bends, which +is every opponent in a racing game. `camera` in `follow` mode with a `lag` +is what sits behind the car. + +`walker` with `follow = "hero"` keeps an instance after an entity (or the +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, +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 +placed. + +`body` is a thing physics moves (mass, bounce, friction); `thrust` pushes a +body along its nose by `dy` and turns it by `dx` (a hovercraft, a ship); +`joint` hangs a body from another's, or from the world, by a hinge, a ball, +or a slider through a point along an axis; and `push` shoves a body, or a +ragdoll's bone. + +`testScripts/author/racing.game` (a car, a rival on a track, checkpoint +triggers, a hinged bar, a hovercraft) and `towerdefence.game` (creeps in +waves on a lane, turrets placed by clicks, gold and lives) are the worked +examples; scenes 66 and 67 play them. + +=== Arcade plumbing: branching video, lives, credits, the board + +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" }` +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 +`bezel` layer shows `score`, `lives`, and `credits` (and a second player's +`score2` and `lives2` with `twin`) on the arcade panel around the picture. + +`gameOver` ends the rules, keeps the best score across runs, and shows the +results card; `submitScore` sends the score to the master service's board +for the game, queued until it can go. `testScripts/author/fmv.game` is the +worked example and scene 68 plays it: one window answered, three missed, the +lives spent, the score sent, a coin, and a continue. + +=== The long tail: sprite sheets, tiles, MIDI, water, soft bodies + +`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 +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. + +A `sprite` look turns with the var `angle` (degrees, clockwise), which an +entity's `rz` starts and which rules, a `spin` behaviour, or a `projectile` +with `aim` change -- a turning image is a whole one, not a sheet. `spin` +turns a 3D instance about Y the same way. A `particles` look is a steady +stream from the instance -- a fire, smoke, a thruster, rain -- with a colour, +a rate, a size, a speed, and a life, and the `emit` action is a burst from any +instance. + +A `sprite` look with `frames` is a sheet of that many columns, and a `frames` +behaviour steps it: `states = "idle=1-1, walk=2-5"` gives each state its run +of frames, played at `fps`, so a walker's walk cycle follows the state the +`walker` (or a rule) sets. Art need only face one way: the look's `faces` +says which (`right` unless said), and while the var `facing` -- which the +`walker` and the `mover` set as they go -- is the other way, the image is +mirrored. A sheet drawn facing both ways names the other way's frames for +the state with `Left` or `Right` on the end (`walkLeft=6-9`), and those are +used instead of the mirror; `faces = "none"` never mirrors, for a top-down +sprite that turns rather than flips. A sheet entity with an `rz` (or a var +`angle`) draws its frame turned. A `grid` look draws a map of tiles from a sheet -- +`columns` tiles wide, each `tile` pixels square -- from rows of tile numbers +(`map = "1 2 1; 2 1 2"`, `0` for nothing), which is a maze or a level in a +string. A game that opened a MIDI port hears `midi` events with +`event.pitch` and `event.velocity` for every note struck. + +In the scene, a mesh look with `water` is filled with water: its top is the +surface, and a `body` with `buoyancy` floats in it; a `vehicle` of type +`boat` with `thrust` is pushed by its propeller while that sits under the +surface; and `soft` makes a mesh look a cloth or an inflated body. +`testScripts/author/tail.game` and `pool.game` are the worked examples; +scenes 70 and 71 play them. + === The way out -The `lua` action takes a line of Lua and emits it as it stands. It is there on -purpose: when a rule needs something the vocabulary cannot say, that rule drops -to Lua and the rest of the game is unaffected. A description is a convenience, +The `lua` action takes a line of Lua and emits it as it stands. It is there on +purpose: when a rule needs something the vocabulary cannot say, that rule drops +to Lua and the rest of the game is unaffected. A description is a convenience, not a cage, and the compiled output is a normal game you can stop describing and start editing whenever it suits you. -=== The editor +== The Vocabulary -`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has +Conditions, actions, behaviours, looks, layers, and events are not built into +the compiler. Each is an entry in the `AUTHOR` table in `Author.singe` +declaring its parameters and the Lua it emits, so a new kind of game is a set +of entries rather than a new release. The tables below are generated from +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), +`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. + +include::ForgeVocabulary.adoc[] + +== The Editor + +`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has its own directory beside the games, appears in the menu like one, and packs to -`Forge.game` with `--pack`; the build does that itself (the `forge` target), -with this manual inside. -Nothing in it is a preview: the canvas is the same overlay at the same +`Forge.game`; the build does that itself (the `forge` target), with this manual +inside. Nothing in it is a preview: the canvas is the same overlay at the same coordinates the game will be played in, so what is placed is what is seen. -Started from the menu it opens on a chooser: the descriptions in its data -directory, any dropped into the `Forge` directory itself (those are opened as a -copy, since inside a `.game` they are read only), and *New game*, which writes +Started from the menu it opens on a chooser: the descriptions in its data +directory, the samples inside `Forge.game` and any dropped into the `Forge` +directory itself (those are opened as a copy, since inside a `.game` they are +read only), and *New game*, which writes a starter -- ground, a hero that runs and jumps, a score readout -- and opens -it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into -that data directory, and the chooser says where it is. `Esc` in the editor closes the description (twice, when it has unsaved -changes) and `Esc` on the chooser leaves Forge. `P` plays: the description is -saved, compiled beside a copy of the runtime, and handed to the engine with -`scriptPush`; when the game ends Forge comes back on the same file. +it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into +that data directory, and the chooser says where it is. `Esc` in the editor +closes the description (twice, when it has unsaved changes) and `Esc` on the +chooser leaves Forge. `P` plays: the description is saved, compiled beside a +copy of the runtime, and handed to the engine with `scriptPush`; when the game +ends Forge comes back on the same file. A script can drive the editor instead, which is how the test scenes do it: @@ -155,99 +483,153 @@ function onOverlayUpdate() end ---- -The entity list and the details are an RmlUi document; the canvas beside them is -drawn into the overlay and picked with `collidePointRect`. -The two 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 -- so -point `forgePress`, `forgeDrag` and `forgeRelease` at the mouse -callbacks and clicks on the panels will not reach the canvas. +The panel is an RmlUi document; the canvas beside it is drawn into the overlay +and picked with `collidePointRect`. The two 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 -- so point `forgePress`, `forgeDrag`, and +`forgeRelease` at the mouse callbacks and clicks on the panel will not reach +the canvas. The panel slides: drag the tab on its outer edge, or use the +arrow keys, so an entity that lives underneath it is never permanently out of +reach. -`forgeSave()` writes the description back, `forgeBuild(path)` -compiles what is on screen (and puts the runtime beside it, so the result -plays), and `forgeMove(index, x, y)` moves an entity without a pointer, which -is how `testScripts/scene54.singe` drives it. +=== The four panels -=== Editing the entities +`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. -`A` adds a box in the middle of the canvas, `D` duplicates the selected entity -a little to one side, and `Delete` removes it. `Enter` walks the entity's -fields the same way it walks a rule's parameters: `id`, `x`, `y`, the look's -`kind`, then whatever that look takes, then `behaviours` -- typed as a list of -kinds, `platformer, solid` -- and then each behaviour's own parameters as -`platformer.speed` and so on. The fields come from the `AUTHOR` manifest, so a -new look or behaviour is editable the moment it is declared. Renaming an -entity renames it in every rule that talks about it. A `sprite` look is drawn -with its image once the file name is right, and as a box until then. +*Entities*: `A` adds one of the selected kind 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 +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`). -From a script, `forgeEntityAdd(x, y)`, `forgeEntityDuplicate()`, -`forgeEntityDelete()` and `forgeEntitySet(entity, field, value)` do the same. +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. -=== Editing the rules +*Kinds*: `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 +every entity and rule. -The panel shows either the entities or the event sheet; `Tab` swaps them. In -the rules, the selected rule opens in place and its conditions and actions are -listed under it, because a rule only means anything whole -- a `when` without a -`then` tells you nothing. +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. -Point `onKeyPressed` at `forgeKey` and the whole editor -works without a pointer, which is how the bundled menu has always been driven -and what a cabinet wants: +*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 +what it is on (the event's own parameters then appear as fields on the rule), +`C` adds a condition, `O` a condition in the rule's `any` group, `T` an action +-- each picked from the vocabulary with its help beside it -- and `[` and `]` +move the rule up and down the sheet, since rules run in order. `Delete` +removes the selected condition or action, or the rule itself. + +*Tracks*: the room's tracks and their keys. The canvas shows the frame of the +disc layer's video under the cursor and every track's box there; `,` and `.` +step the cursor a frame, `[` and `]` leap ten. `N` adds a track, `K` a key at +the cursor -- taking the box interpolated there, or the nearest key's -- and a +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. + +*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. +`A` adds at the level selected -- a dialogue, a node in it, a choice in the +node -- `T` adds an action to the selected choice from the vocabulary, and +`Delete` removes what is selected. A dialogue's `name` and `start`, a node's +`name`, `who`, `text`, `seconds`, and `next`, and a choice's `text`, `when`, +`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. + +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 +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. +`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 +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 +is picked by where it projects and dragged across the floor it stands on, and +the selected one carries a gizmo: three arms along the world axes, red for +X, green for Y, blue for Z, each ending in a handle that drags the entity +along that axis alone -- the pointer's travel along the arm, as a fraction of +the arm, is the distance moved -- and a fourth, yellow, the way the entity +faces, whose handle dragged round the entity turns it about Y. `testScripts/scene63.singe` and `scene80` +drive it. + +=== Keys [cols="1,4"] |=== -| `Tab` | entities or rules -| Up, Down | move through the list, and through the parts of the open rule +| `Tab` | entities, kinds, rules, tracks +| Up, Down | move through the list, and through the parts of the open rule or track | Left, Right | slide the panel +| `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 -| `A`, `D`, `Delete` | add, duplicate, delete the selected entity -| `N` | a new rule -| `C`, `T` | add a condition, an action -- picked from the vocabulary, with its help beside it -| `[`, `]` | move the rule up or down the sheet -| `Delete` | delete the selected rule, or the selected condition or action +| `Esc` | put the value back; otherwise close the description +| `A`, `D`, `Delete` | add, duplicate, delete an entity or a kind +| `N` | a new rule, a new track, or (in the entities) a new room +| `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 +| `,`, `.` | step the cursor +| `V` | draw a walk area, or the selected hotspot's outline, corner by corner +| `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 |=== -With the rule itself selected rather than one of its parts, `Enter` edits its -note. +From a script the same work is `forgeEntityAdd`, `forgeEntityDuplicate`, +`forgeEntityDelete`, `forgeEntitySet`, `forgeKindAdd`, `forgeKindSet`, +`forgeRuleNew`, `forgeRuleOn`, `forgeRuleAdd("when"|"any"|"act", name)`, +`forgePartSet`, `forgePartDelete`, `forgeRuleMove`, `forgeTrackNew`, +`forgeKeyAdd`, `forgeCursorTo`, `forgeUndo`, and `forgeRedo`. -A typed number comes back a number rather than a string, because `100` and -`"100"` compile to different source and a description that changed shape when a -value was retyped would stop round-tripping. - -From a script, `forgeRuleNew(note)`, `forgeRuleAdd("when"|"act", -name)`, `forgePartSet(key, value)`, `forgePartDelete()`, `forgeRuleMove(by)`, -`forgeUndo()` and `forgeRedo()` do the same work. Adding a condition selects it, so its -parameters can be set at once. - -The vocabulary comes from the `AUTHOR` manifest, so the rule editor never needs -changing when a condition or an action is added: it offers whatever is -declared. A new parameter gets a sensible starting value for its type, so a -rule compiles the moment it is made rather than only once every field is -filled in. - -A description survives the round trip: loading one, saving it and loading it -again compiles to the same game, byte for byte. The editor depends on that and -the test asserts it. +A description survives the round trip: loading one, saving it, and loading it +again compiles to the same game, byte for byte. The editor depends on that +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, +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` +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. 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 +`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the engine was *launched* with, so a game reached by `dofile` -- a test, a launcher, a preview -- would otherwise look beside the caller. -The panel slides. Drag the tab on its outer edge and it moves across the -window, so an entity that lives underneath it -- a score readout at `12, 12` -does -- is never permanently out of reach. `forgePanelTo(x)` moves it from -a script, and `forgeOverPanel(x)` says whether a point is currently -covered. - -[#migrating] +`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 +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 new file mode 100644 index 000000000..b89028733 --- /dev/null +++ b/docs/ForgeVocabulary.adoc @@ -0,0 +1,687 @@ +// Generated by util/forgeVocabulary.lua from assets/Forge/Author.singe. Do not edit. + +=== Layers + +[cols="1,3,2"] +|=== +| Name | What it does | Parameters + +| `bezel` +| The arcade score panel around the picture, showing the vars score (and score2), lives (and lives2), and credits as they change. +| `twin` (boolean) + +| `disc` +| The video the game is played over. The file is what the editor scrubs; games.dat names it when the game runs. +| `file` (file), `start` (number) + +| `hud` +| An RmlUi document over everything. bind maps element ids to game vars, refreshed as they change. +| `bind` (table), `document` (file) + +| `music` +| A track that plays from the start, looping. +| `file` (file), `volume` (number) + +| `overlay` +| Flat drawing over everything: sprites, scores, prompts. +| -- + +| `parser` +| A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event. +| `ignore` (table), `prompt` (string), `words` (table) + +| `scene3d` +| The 3D scene: physics in three axes, a sun, a sky, fog. Positions are world units. +| `ambient` (table), `exposure` (number), `fog` (table), `fov` (number), `gravity` (number), `sky` (file), `sun` (table) + +| `world2d` +| A flat world with gravity: physics in the XY plane, drawn into the overlay. +| `gravity` (number) + +|=== + +=== Looks + +[cols="1,3,2"] +|=== +| Name | What it does | Parameters + +| `billboard` +| A sprite in the scene, always facing the camera: a tree, a puff, a health bar. +| `d` (number), `file` (file), `h` (number), `height` (number), `w` (number) + +| `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) + +| `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. +| `columns` (number), `file` (file), `map` (string), `tile` (number) + +| `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) + +| `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) + +| `model` +| A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as. +| `clip` (string), `d` (number), `file` (file), `h` (number), `rx` (number), `ry` (number), `rz` (number), `scale` (number), `w` (number) + +| `none` +| Nothing drawn: a trigger, a spawner, a hitbox over video, a camera. +| `d` (number), `h` (number), `w` (number) + +| `particles` +| A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them. +| `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number) + +| `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) + +| `text` +| A line of text, its top left at the instance. The var text is what it says. +| `b` (number), `g` (number), `r` (number), `text` (string) + +| `text3d` +| A line of text standing in the scene. The var text is what it says. +| `height` (number), `text` (string) + +|=== + +=== Behaviours + +[cols="1,3,2"] +|=== +| Name | What it does | Parameters + +| `animator` +| A clip per state: idle, walk, attack, hit, dead, or any state a rule sets. A clip that does not loop raises animationDone when it ends. +| `attack` (string), `dead` (string), `fade` (number), `hit` (string), `idle` (string), `loop` (string), `walk` (string) + +| `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) + +| `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. +| `track` (track) + +| `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) + +| `character` +| Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera. +| `height` (number), `jump` (number), `radius` (number), `speed` (number) + +| `drift` +| Moves steadily, for a cloud, a platform, or a target. +| `vx` (number), `vy` (number) + +| `frames` +| Steps a sprite look's frame: a range of frames per state, as "idle=1-1, walk=2-5", at a rate a second. A sheet drawn facing both ways names the other way's frames stateLeft or stateRight (walkLeft), used while the var facing is that way instead of mirroring the look. +| `fps` (number), `states` (string) + +| `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) + +| `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. +| `keep` (boolean), `linger` (number), `max` (number), `ragdoll` (boolean) + +| `hitbox` +| The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none. +| `track` (track) + +| `hotspot` +| Something a verb can be used on: a name for the sentence line, a place to walk to first, and a polygon (x,y pairs) when its look's box will not do. +| `name` (string), `polygon` (table), `walkX` (number), `walkY` (number), `walkZ` (number) + +| `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) + +| `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. +| `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode) + +| `mover` +| Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys. +| `clamp` (boolean), `speed` (number) + +| `patrol` +| Walks a list of points in turn -- a creep's lane, a guard's round -- looping or stopping, and raises patrolEnd at the last. +| `loop` (boolean), `points` (table) + +| `platformer` +| Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. +| `jump` (number), `speed` (number) + +| `projectile` +| Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies. +| `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number) + +| `racer` +| Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is. +| `reach` (number), `throttle` (number), `track` (track) + +| `seek` +| Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it. +| `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) + +| `soft` +| A mesh look made soft: a cloth, or a body inflated to a pressure. +| `mass` (number), `pressure` (number), `stiffness` (number), `type` (string) + +| `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. +| `moving` (boolean) + +| `sound` +| A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's. +| `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) + +| `spin` +| Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second. +| `rate` (number) + +| `target` +| Something a gun can hit. In 2D its look's box; in 3D a body the size of its look, plus zones -- boxes on named bones, so a shot says which part it hit. +| `zones` (table) + +| `thrust` +| A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift. +| `force` (number), `turn` (number) + +| `timer` +| Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart. +| `after` (number), `every` (number), `name` (string), `start` (boolean) + +| `trigger` +| A volume that reports what enters and leaves it, as the enter and leave events. A checkpoint, a doorway, a prize. +| -- + +| `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) + +| `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) + +| `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. +| `every` (number), `follow` (string), `radius` (number), `speed` (number), `stopAt` (number) + +| `water` +| Fills a mesh look with water: its top is the surface, and bodies inside float or sink by their buoyancy. +| `density` (number), `drag` (number) + +|=== + +=== Events + +[cols="1,3,2"] +|=== +| Name | What it does | Parameters + +| `animationDone` +| A clip that does not loop ended on self; event.state says which state it was for. +| `kind` (kind), `state` (state) + +| `arrived` +| A seeking instance reached its target. +| `kind` (kind) + +| `branchMissed` +| The window closed without the move and the disc went to the fail frame. +| -- + +| `branchOpen` +| A branch's window opened; event.move says what it wants. +| -- + +| `branchTaken` +| The move was made in time and the disc went to the success frame. +| -- + +| `collision` +| An instance of kind a began touching one of kind b (self is a, other is b). +| `a` (kind), `b` (kind) + +| `death` +| self's health reached zero. +| `kind` (kind) + +| `enter` +| An instance of kind b entered a trigger of kind a (self is the trigger, other what entered). +| `a` (kind), `b` (kind) + +| `frame` +| Every frame. +| -- + +| `frameReached` +| The disc passed a frame. +| `frame` (number) + +| `gameOver` +| The game ended. +| -- + +| `hit` +| A gun shot landed on self; other is the gun. +| `kind` (kind), `player` (number) + +| `leave` +| An instance of kind b left a trigger of kind a. +| `a` (kind), `b` (kind) + +| `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.) +| `pitch` (number) + +| `miss` +| A gun shot hit nothing; self is the gun. event.offscreen says whether it left the screen. +| `player` (number) + +| `patrolEnd` +| A patrolling instance reached the last of its points. +| `kind` (kind) + +| `pressed` +| A key or a switch went down. +| `key` (scancode), `player` (number), `switch` (switch) + +| `railEnd` +| A rail camera reached the end of its track. +| -- + +| `released` +| A key or a switch came up. +| `key` (scancode), `player` (number), `switch` (switch) + +| `roomEnd` +| A room is about to be left. +| `room` (room) + +| `roomStart` +| A room has been entered. +| `room` (room) + +| `said` +| 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) + +| `spawn` +| self has just been made. +| `kind` (kind) + +| `stopped` +| A rail camera reached a stop; event.name says which. pathNext moves it on. +| `name` (string) + +| `timer` +| A timer on self went off; event.name says which. +| `kind` (kind), `name` (string) + +| `verb` +| A verb was used on a hotspot (self), with an item or not: "use key on door". The most specific rule wins. +| `item` (string), `target` (string), `verb` (string) + +|=== + +=== Conditions + +[cols="1,3,2"] +|=== +| Name | What it does | Parameters + +| `below` +| An instance has fallen past a line -- a pit, or the bottom of the screen. +| `entity` (entity), `y` (number) + +| `chance` +| True with a probability, 0 to 1. +| `p` (number) + +| `discBetween` +| The disc is between two frames -- the window a QTE is answered in. +| `from` (number), `to` (number) + +| `every` +| True once every so many seconds, for a rule that fires steadily. +| `seconds` (number), `tag` (string) + +| `has` +| The inventory holds an item. +| `item` (string) + +| `hitPart` +| The shot landed on a named zone of the target: head, weakspot. +| `part` (string) + +| `hover` +| A player's pointer is over the named hotspot. +| `player` (number), `target` (string) + +| `inState` +| An instance's state is this. +| `entity` (entity), `state` (state) + +| `keyHeld` +| A key is down. +| `key` (scancode) + +| `keyPressed` +| A key went down this frame. +| `key` (scancode) + +| `onGround` +| A platformer has ground under it. +| `entity` (entity) + +| `onScreen` +| An instance's box is at least partly on the overlay. +| `entity` (entity) + +| `once` +| Only the first time this rule would run -- per instance and per room unless scope is "game". +| `scope` (string), `tag` (string) + +| `pointerIn` +| A player's pointer is over an instance. +| `entity` (entity), `player` (number) + +| `pointerOffscreen` +| A player's pointer is off the picture, which is how a light gun reloads. +| `player` (number) + +| `switchHeld` +| A pad, gun, or mouse switch is down. +| `switch` (switch) + +| `test` +| An expression is true: self.health <= 0, count("zombie") == 0, score > 1000. +| `expr` (expression) + +| `timeBetween` +| The game is between two moments, in seconds. +| `from` (number), `to` (number) + +| `touching` +| Two instances overlap. +| `entity` (entity), `other` (entity) + +| `waiting` +| A rail camera is held at a stop. +| `entity` (entity) + +|=== + +=== Actions + +[cols="1,3,2,1"] +|=== +| Name | What it does | Parameters | Waits + +| `addScore` +| Add to the score. +| `amount` (number) +| + +| `addVar` +| Add to a var on an instance, or on the game with no entity. +| `amount` (number), `entity` (entity), `name` (string) +| + +| `cameraCut` +| Draw the scene from another camera instance. +| `entity` (entity) +| + +| `credit` +| A coin: one more credit. +| -- +| + +| `damage` +| Take from an instance's health; at zero it dies. +| `amount` (number), `entity` (entity) +| + +| `destroy` +| Remove an instance. +| `entity` (entity) +| + +| `die` +| The Sierra death: a message, then the game starts over. +| `text` (expression) +| yes + +| `discTo` +| Send the disc to a frame -- the branch a QTE takes. +| `frame` (number) +| + +| `emit` +| A burst of particles at an instance: an explosion, sparks, a puff. +| `b` (number), `count` (number), `entity` (entity), `g` (number), `r` (number), `speed` (number) +| + +| `face` +| Turn a walker to face left or right, or toward an instance. +| `direction` (string), `entity` (entity), `target` (entity) +| + +| `fade` +| Fade the picture to black, or back (with out false), and wait. +| `out` (boolean), `seconds` (number) +| yes + +| `flash` +| Flash the screen a colour. +| `b` (number), `g` (number), `r` (number), `seconds` (number) +| + +| `gameOver` +| End the game: rules stop, the gameOver event fires, restart begins again. +| -- +| + +| `give` +| Put an item in the inventory. +| `item` (string) +| + +| `goTo` +| 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. +| `at` (string), `entity` (entity), `room` (room), `x` (number), `y` (number), `z` (number) +| + +| `jump` +| Ask a platformer to jump. +| `entity` (entity) +| + +| `loadGame` +| Bring a slot back. +| `slot` (number) +| + +| `lookAt` +| Turn an instance toward another. +| `entity` (entity), `target` (entity) +| + +| `lua` +| Anything the vocabulary cannot say. The way out, and it is meant to be used. +| `code` (lua) +| + +| `moveTo` +| Put an instance somewhere. +| `entity` (entity), `x` (number), `y` (number), `z` (number) +| + +| `nextVerb` +| Cycle to the next verb in the game's list. +| -- +| + +| `pathNext` +| Send a rail camera on from its stop. +| `entity` (entity) +| + +| `playAnimation` +| Play a model's clip, once or looping. +| `clip` (string), `entity` (entity), `loop` (boolean) +| + +| `playMusic` +| Play a track, looping. +| `file` (file) +| + +| `playSound` +| Play a clip, at a volume of its own (0 to 100) under the game's. +| `file` (file), `volume` (number) +| + +| `push` +| Shove a body, or a ragdoll's bone, along a direction. +| `bone` (string), `entity` (entity), `x` (number), `y` (number), `z` (number) +| + +| `reload` +| Fill a gun's ammo. +| `entity` (entity) +| + +| `restart` +| Begin the game again from its first room. +| -- +| + +| `run` +| Drive a platformer left (-1) or right (1) this frame. +| `direction` (number), `entity` (entity) +| + +| `saveGame` +| Keep the whole game -- room, positions, vars, inventory -- in a numbered slot. +| `slot` (number) +| + +| `say` +| Show a line for a while, and wait for it. With a hud it goes in the element called say; otherwise it is drawn. +| `seconds` (number), `text` (expression) +| yes + +| `setState` +| Change an instance's state. +| `entity` (entity), `state` (state) +| + +| `setText` +| Change what a text instance says. +| `entity` (entity), `text` (expression) +| + +| `setVar` +| Set a var on an instance, or on the game with no entity. +| `entity` (entity), `name` (string), `value` (expression) +| + +| `setVerb` +| Choose the verb the next click uses. +| `verb` (string) +| + +| `shake` +| Shake the camera for a moment. +| `amount` (number), `seconds` (number) +| + +| `show` +| Show or hide an instance. +| `entity` (entity), `visible` (boolean) +| + +| `spawn` +| Make an instance of a kind at a point. +| `kind` (kind), `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) +| + +| `stopMusic` +| Stop the track that is playing. +| -- +| + +| `submitScore` +| Send the score to the master service's board for this game, queued until it can go. +| `board` (string) +| + +| `take` +| Take an item out of the inventory. +| `item` (string) +| + +| `talk` +| Run a dialogue from the game's dialogues, and wait for it to end. +| `dialogue` (string) +| yes + +| `timerStart` +| Start a named timer on an instance. +| `after` (number), `entity` (entity), `name` (string) +| + +| `useItem` +| Choose an inventory item for the next click: "use key on ...". +| `item` (string) +| + +| `wait` +| Wait some seconds before the next action. +| `seconds` (number) +| yes + +| `walkTo` +| Send a walker to a point, and wait until it gets there. +| `entity` (entity), `x` (number), `y` (number), `z` (number) +| yes + +| `walkToHotspot` +| Send a walker to a hotspot's walk point (the one the verb was used on, with self), and wait. +| `entity` (entity), `target` (entity) +| yes + +| `walkToPointer` +| Send a walker where the player's pointer is, and wait. +| `entity` (entity), `player` (number) +| yes + +|=== + diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 29104e5b2..8e46b8609 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -8591,6 +8591,37 @@ nodeSetPosition(wall, 0, 2, -3) nodeSetRotation(wall, 90, 0, 0) ---- +[#materialsetoccluder] +==== materialSetOccluder + +[source,text] +---- +materialSetOccluder(material, occluder) +---- + +Makes the material write depth and no colour. Whatever the mesh covers is +hidden as if the mesh were solid, but the mesh itself is not seen: what was +drawn behind it -- a painted backdrop, the disc -- shows through. It is the +stand-in for something that is in a picture but not in the scene: a pillar in +a painted room that a 3D character has to walk behind, a doorway, a table. +An occluder casts no shadow, since the painting already has one, and takes +no light. `false` (the default) makes the material ordinary again. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A pillar that is in the painting: an invisible box where it stands, so +-- the hero disappears behind it and reappears on the other side. +hidden = materialNew() +materialSetOccluder(hidden, true) +pillar = nodeNew() +nodeSetMesh(pillar, meshBox(0.8, 4, 0.8), hidden) +nodeSetPosition(pillar, 2, 2, -3) +---- + [#materialsetocclusionmap] ==== materialSetOcclusionMap @@ -15244,19 +15275,21 @@ end ---- channel = soundPlay(id) channel = soundPlay(id, loops) +channel = soundPlay(id, loops, volume) ---- -Starts a loaded clip on the first free channel and returns that channel's number. A channel counts as free when it is neither playing nor paused. The new channel starts at the master volume, centered, with no scene position and the default range, whatever the channel's previous use set. When all sixteen channels are busy nothing plays and `-1` (`SOUND_ERROR_INVALID`) comes back; a game that fires many overlapping sounds should check for it. A handle that is not loaded aborts the script. +Starts a loaded clip on the first free channel and returns that channel's number. A channel counts as free when it is neither playing nor paused. The new channel starts at the master volume (or at `volume` under it), centered, with no scene position and the default range, whatever the channel's previous use set. When all sixteen channels are busy nothing plays and `-1` (`SOUND_ERROR_INVALID`) comes back; a game that fires many overlapping sounds should check for it. A handle that is not loaded aborts the script. *Parameters:* * `id` -- sound handle from `soundLoad`. * `loops` -- `0` (the default) plays the clip once, a positive count repeats it that many more times, `-1` loops it until it is stopped. Anything below `-1` aborts the script. +* `volume` -- the channel's own volume, `0` through `63`, under the master; the default is `63`, the clip as loud as the master allows. Outside that range aborts the script. `soundSetChannelVolume` changes it while the clip plays. *Returns:* integer channel number, `0` to `15`, or `-1` when no channel is free. -*Since:* 1.x -*See also:* <>, <>, <> +*Since:* 1.x; `volume` since 3.00. +*See also:* <>, <>, <>, <> .Example [source,lua] @@ -15302,6 +15335,36 @@ function onSoundCompleted(channel) end ---- +[#soundsetchannelvolume] +==== soundSetChannelVolume + +[source,text] +---- +soundSetChannelVolume(channel, volume) +---- + +Sets one channel's own volume, on top of the master from `soundSetVolume`: the channel plays at the master times this, times its distance fade when it is in the scene. Every channel starts at full when `soundPlay` takes it, unless `soundPlay` was given a volume, so a quiet footstep and a loud shot can share one master. Values outside `0` to `63` abort the script, and so does a channel outside `0` to `15`. + +*Parameters:* + +* `channel` -- channel number from `soundPlay`. +* `volume` -- integer, `0` (silent) through `63` (as loud as the master). + +*Since:* 3.00 +*See also:* <>, <> + +.Example +[source,lua] +---- +-- An engine that gets louder with the throttle: one looping clip, its channel's volume +-- following the pedal every frame. +engine = soundPlay(engineClip, -1, 20) + +function onOverlayUpdate() + soundSetChannelVolume(engine, math.floor(20 + throttle * 43)) +end +---- + [#soundsetlistener] ==== soundSetListener @@ -15442,14 +15505,14 @@ soundSetRange(siren, 10, 200) soundSetVolume(volume) ---- -Sets the master effects volume and applies it at once to every channel, playing or paused. Positioned channels keep their distance fade on top of it. Values outside `0` to `63` abort the script. +Sets the master effects volume and applies it at once to every channel, playing or paused. Each channel's own volume from `soundSetChannelVolume` and, for positioned channels, the distance fade stay on top of it. Values outside `0` to `63` abort the script. *Parameters:* * `volume` -- integer, `0` (silent) through `63` (loudest). *Since:* 1.16 -*See also:* <> +*See also:* <>, <> .Example [source,lua] @@ -15592,7 +15655,7 @@ spriteDrawFrame(id, x, y, frame, scale) spriteDrawFrame(id, x, y, frame, scaleX, scaleY) ---- -Hypseus extension. Blits one frame of a sprite sheet (or of any animation) onto the overlay with `(x, y)` as its top-left corner, without changing which frame the sprite is parked on or whether it is playing. Frames are numbered from `1`, as Hypseus counts them; a frame outside `1` to the frame count draws frame `1`. A still image draws itself and ignores `frame`. The sprite's own rotation and scale from `spriteRotate` and `spriteScale` are ignored: the first form draws the frame at its natural size, the others multiply the frame's width by `scaleX` (or `scale`) and its height by `scaleY` (or `scale`) with nearest-neighbor sampling. Call it from `onOverlayUpdate`. +Hypseus extension. Blits one frame of a sprite sheet (or of any animation) onto the overlay with `(x, y)` as its top-left corner, without changing which frame the sprite is parked on or whether it is playing. Frames are numbered from `1`, as Hypseus counts them; a frame outside `1` to the frame count draws frame `1`. A still image draws itself and ignores `frame`. A mirror set by `spriteFlip` applies. The sprite's own rotation and scale from `spriteRotate` and `spriteScale` are ignored: the first form draws the frame at its natural size, the others multiply the frame's width by `scaleX` (or `scale`) and its height by `scaleY` (or `scale`) with nearest-neighbor sampling. Call it from `onOverlayUpdate`. *Parameters:* @@ -15695,6 +15758,40 @@ spriteRotateFrame(needle, heading, 1) spriteDrawRotatedFrame(needle, 320, 240) ---- +[#spriteflip] +==== spriteFlip + +[source,text] +---- +spriteFlip(id, horizontal, vertical) +---- + +Mirrors the sprite left to right, top to bottom, or both, until it is called again with `false, false`. The mirror applies to every draw of the sprite: `spriteDraw`, each frame from `spriteDrawFrame`, and the turned frame from `spriteDrawRotatedFrame`. It is applied after the rotation and scale from `spriteRotate` and `spriteScale`, so a turned sprite is mirrored as it appears on screen, and a turned frame is simply mirrored the other way rather than turned again. The mirrored image is rebuilt only when the flags change; a frame drawn by `spriteDrawFrame` is mirrored once and kept until another frame is asked for. `spriteDrawGrid` ignores the mirror, since its rectangle is cut from the unmirrored image. + +A character that walks both ways needs only art that faces one way: flip the sprite when it turns round. + +*Parameters:* + +* `id` -- sprite handle. +* `horizontal` -- `true` mirrors left to right. +* `vertical` -- `true` mirrors top to bottom. + +*Returns:* nothing. + +*Since:* 3.00 +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- The hero's sheet faces right; facing left is the same frames, mirrored. +function onOverlayUpdate() + overlayClear() + spriteFlip(hero, heroVx < 0, false) + spriteDrawFrame(hero, heroX, heroY, walkFrame) +end +---- + [#spriteframeheight] ==== spriteFrameHeight diff --git a/src/main.c b/src/main.c index 198d01ddf..f7a44b3ad 100644 --- a/src/main.c +++ b/src/main.c @@ -1555,7 +1555,7 @@ static bool _runTool(const ConfigT *conf) { // know so that later versions may add them. static void _showApiVersion(void) { // VERSION_STRING is "vX.YY" for people; a machine wants the number on its own. - printf("singe version=%s protocol=%d\n", VERSION_STRING + 1, API_VERSION_PROTOCOL); + printf("singe version=%s protocol=%d\n", &VERSION_STRING[1], API_VERSION_PROTOCOL); fflush(stdout); exit(EXIT_SUCCESS); } diff --git a/src/navRecast.cpp b/src/navRecast.cpp index 84fd5e341..793f6a491 100644 --- a/src/navRecast.cpp +++ b/src/navRecast.cpp @@ -563,6 +563,9 @@ int32_t navAgentNew(int32_t nav, int32_t node, float radius, float height, float } radius = SDL_max(radius, MIN_AGENT_SIZE); height = SDL_max(height, MIN_AGENT_SIZE); + // A node placed this frame has no world matrix until the scene walks them, and a game that + // never draws the scene (a 2D room walking a mesh) never walks them at all. + sceneUpdateTransforms(); position = nodeGetWorldPosition(node); if (_nearestPoly(record, position, start) == 0) { start[0] = position.x; diff --git a/src/scene.c b/src/scene.c index 22b49727b..0c969db51 100644 --- a/src/scene.c +++ b/src/scene.c @@ -63,10 +63,11 @@ #define SH_SAMPLES_ACROSS 128 // Equirect columns sampled for the harmonics #define HALF_BYTES 2 #define CUBE_CHANNELS 4 -#define PIPELINE_COUNT 8 // skinned x blend x double sided +#define PIPELINE_COUNT 16 // skinned x blend x double sided x occluder #define PIPELINE_SKINNED 1 #define PIPELINE_BLEND 2 #define PIPELINE_TWO_SIDED 4 +#define PIPELINE_OCCLUDER 8 // Writes depth and no colour: a stand-in for something painted #define SHADOW_PIPELINES 8 // skinned x double sided x cutout #define SHADOW_PIPELINE_SKINNED 1 #define SHADOW_PIPELINE_TWO_SIDED 2 @@ -245,6 +246,7 @@ typedef struct MaterialS { bool unlit; bool doubleSided; bool blend; + bool occluder; // Depth only: hides what is behind it and shows what is behind itself bool textureBorrowed; // The base texture belongs to a sprite node: never released here bool used; } MaterialT; @@ -1218,6 +1220,12 @@ static bool _createPipeline(int32_t sampleSet, int32_t variant) { if (variant & PIPELINE_BLEND) { _alphaBlendState(&colour, false); } + if (variant & PIPELINE_OCCLUDER) { + // The shape goes into the depth buffer and nowhere else, so a painted backdrop stays in + // view where it stands while whatever walks behind it is hidden. + colour.blend_state.enable_color_write_mask = true; + colour.blend_state.color_write_mask = 0; + } info.vertex_shader = (variant & PIPELINE_SKINNED) ? _scene.vertexSkinned : _scene.vertexStatic; info.fragment_shader = _scene.fragment; info.vertex_input_state.vertex_buffer_descriptions = &buffer; @@ -1811,7 +1819,8 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i continue; } if (shadowPass) { - if (!node->shadowCaster && !_scene.depthPrepass) { + // An occluder stands for something already painted, shadows included. + if ((!node->shadowCaster && !_scene.depthPrepass) || material->occluder) { continue; } // A bulb inside a closed mesh sees only its back faces; they must still cast. @@ -3021,6 +3030,9 @@ static int32_t _pipelineVariant(int32_t node) { if (material->doubleSided) { variant |= PIPELINE_TWO_SIDED; } + if (material->occluder) { + variant |= PIPELINE_OCCLUDER; + } } return variant; } @@ -4113,6 +4125,15 @@ bool materialSetMetallic(int32_t material, float metallic) { } +bool materialSetOccluder(int32_t material, bool occluder) { + if (!materialValid(material)) { + return false; + } + _scene.materials[material].occluder = occluder; + return true; +} + + bool materialSetRoughness(int32_t material, float roughness) { if (!materialValid(material)) { return false; diff --git a/src/scene.h b/src/scene.h index b8b858e28..3e29bdbc9 100644 --- a/src/scene.h +++ b/src/scene.h @@ -152,6 +152,7 @@ bool materialSetGui(int32_t material, int32_t gui); // A GUI's texture bool materialSetMap(int32_t material, MaterialMapE map, const Ktx2ImageT *image, float strength); // A transcoded KTX2 image (NULL clears); strength for normal and occlusion bool materialSetMapSurface(int32_t material, MaterialMapE map, SDL_Surface *image, float strength); // The same from a surface (NULL clears) bool materialSetMetallic(int32_t material, float metallic); +bool materialSetOccluder(int32_t material, bool occluder); // Depth only: hides what is behind it, shows the backdrop through it bool materialSetRoughness(int32_t material, float roughness); bool materialSetTiling(int32_t material, float u, float v); bool materialSetUnlit(int32_t material, bool unlit); diff --git a/src/singe.c b/src/singe.c index dd14d6ded..cde0d3aee 100644 --- a/src/singe.c +++ b/src/singe.c @@ -427,6 +427,9 @@ typedef struct SpriteS { SDL_Surface *originalSurface; // Owned unless it points into animation->frames SDL_Surface *surface; // What gets drawn: originalSurface, or a transformed copy SDL_Surface *rotatedFrame; // spriteRotateFrame's turned copy of one sheet frame, drawn by spriteDrawRotatedFrame + SDL_Surface *flippedFrame; // spriteFlip's mirrored copy of the sheet frame spriteDrawFrame last drew + int32_t flippedIndex; // Which frame that is + SDL_FlipMode flip; // How spriteFlip mirrors every draw bool surfaceOwned; // True when surface is a transformed copy to free int32_t frameWidth; // Unscaled size of one frame (the image, for a still) int32_t frameHeight; @@ -480,7 +483,7 @@ typedef struct EffectS { float farOff; // ... silent beyond this float relative[3]; // The last position handed to the mixer, listener space float gain; // The last distance gain - float baseGain; // The effects volume the track gain was last built from + float volume; // The channel's own volume, 0 to 1, on top of the master bool positioned; } EffectT; @@ -785,6 +788,7 @@ static void _drawPauseIndicator(const SDL_FRect *target); static void _drawRing(const SDL_FRect *outer, const SDL_FRect *inner, const SDL_Color *color); static void _drawSindenBorder(void); static void _drawTrails2D(const EmitterViewT *view, const ParticleTexturesT *cache, const SDL_FRect *target, float scaleX, float scaleY); +static void _effectApplyGain(int32_t channel); static float _effectGain(const EffectT *effect, float distance); static void _effectReset(int32_t channel); static void _effectStopped(void *userdata, MIX_Track *track); @@ -941,11 +945,13 @@ static SoundT *_soundLoadFromIO(lua_State *L, const char *method, SDL_IOStr static int32_t _soundQueueDrain(int32_t *finished); static void _spriteAdoptAnimation(SpriteT *sprite); static void _spriteDestroy(SpriteT *sprite); +static SDL_Surface *_spriteFlippedFrame(SpriteT *sprite, SDL_Surface *source, int32_t frame); static void _spriteFreeSurface(SpriteT *sprite); static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io, int32_t width, int32_t height); static SpriteT *_spriteNew(lua_State *L, const char *method); static void _spriteRebuildSurface(SpriteT *sprite); static void _spriteRegister(SpriteT *sprite); +static SDL_Surface *_spriteTransform(SpriteT *sprite, SDL_Surface *source); static const char *_startAudioSuffix(void); static void _startControllers(void); static void _startLuaContext(lua_State *L); @@ -1142,6 +1148,7 @@ static int32_t apiMaterialSetGui(lua_State *L); static int32_t apiMaterialSetMetallic(lua_State *L); static int32_t apiMaterialSetMetallicRoughnessMap(lua_State *L); static int32_t apiMaterialSetNormalMap(lua_State *L); +static int32_t apiMaterialSetOccluder(lua_State *L); static int32_t apiMaterialSetOcclusionMap(lua_State *L); static int32_t apiMaterialSetRoughness(lua_State *L); static int32_t apiMaterialSetTexture(lua_State *L); @@ -1371,6 +1378,7 @@ static int32_t apiSoundLoadData(lua_State *L); static int32_t apiSoundPause(lua_State *L); static int32_t apiSoundPlay(lua_State *L); static int32_t apiSoundResume(lua_State *L); +static int32_t apiSoundSetChannelVolume(lua_State *L); static int32_t apiSoundSetListener(lua_State *L); static int32_t apiSoundSetNode(lua_State *L); static int32_t apiSoundSetPan(lua_State *L); @@ -1384,6 +1392,7 @@ static int32_t apiSpriteDraw(lua_State *L); static int32_t apiSpriteDrawFrame(lua_State *L); static int32_t apiSpriteDrawGrid(lua_State *L); static int32_t apiSpriteDrawRotatedFrame(lua_State *L); +static int32_t apiSpriteFlip(lua_State *L); static int32_t apiSpriteFrameHeight(lua_State *L); static int32_t apiSpriteFrameWidth(lua_State *L); static int32_t apiSpriteGetFrame(lua_State *L); @@ -3108,15 +3117,27 @@ static float _effectGain(const EffectT *effect, float distance) { // A channel about to play afresh: no position, no pan, the default range. +// What a channel's track plays at: the master effects volume, the channel's own volume, and its +// distance gain while it is in the scene. Every gain change goes through here so there is one +// answer to what a channel is worth, and the master can move without losing the rest. +static void _effectApplyGain(int32_t channel) { + EffectT *effect = &_effects[channel]; + + MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME) * effect->volume * (effect->positioned ? effect->gain : 1.0f)); +} + + static void _effectReset(int32_t channel) { EffectT *effect = &_effects[channel]; memset(effect, 0, sizeof(*effect)); - effect->node = LISTENER_CAMERA; + effect->node = LISTENER_CAMERA; effect->nearBy = SOUND_DEFAULT_NEAR; effect->farOff = SOUND_DEFAULT_FAR; - effect->gain = 1.0f; + effect->gain = 1.0f; + effect->volume = 1.0f; MIX_SetTrack3DPosition(_effectTracks[channel], NULL); + _effectApplyGain(channel); } @@ -5909,6 +5930,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "materialSetMetallic", apiMaterialSetMetallic); // 3.00 lua_register(L, "materialSetMetallicRoughnessMap", apiMaterialSetMetallicRoughnessMap); // 3.00 lua_register(L, "materialSetNormalMap", apiMaterialSetNormalMap); // 3.00 + lua_register(L, "materialSetOccluder", apiMaterialSetOccluder); // 3.00 lua_register(L, "materialSetOcclusionMap", apiMaterialSetOcclusionMap); // 3.00 lua_register(L, "materialSetRoughness", apiMaterialSetRoughness); // 3.00 lua_register(L, "materialSetTexture", apiMaterialSetTexture); // 3.00 @@ -6141,6 +6163,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "soundPause", apiSoundPause); // 1.16 RDG lua_register(L, "soundPlay", apiSoundPlay); // 1.xx lua_register(L, "soundResume", apiSoundResume); // 1.16 RDG + lua_register(L, "soundSetChannelVolume", apiSoundSetChannelVolume); // 3.00 lua_register(L, "soundSetListener", apiSoundSetListener); // 3.00 lua_register(L, "soundSetNode", apiSoundSetNode); // 3.00 lua_register(L, "soundSetPan", apiSoundSetPan); // 3.00 @@ -6155,6 +6178,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "spriteDrawFrame", apiSpriteDrawFrame); // Hypseus lua_register(L, "spriteDrawGrid", apiSpriteDrawGrid); // Hypseus lua_register(L, "spriteDrawRotatedFrame", apiSpriteDrawRotatedFrame); // Hypseus Handle first since 3.00. + lua_register(L, "spriteFlip", apiSpriteFlip); // 3.00 lua_register(L, "spriteFrameHeight", apiSpriteFrameHeight); // Hypseus lua_register(L, "spriteFrameWidth", apiSpriteFrameWidth); // Hypseus lua_register(L, "spriteGetFrame", apiSpriteGetFrame); // 2.10 @@ -6352,7 +6376,9 @@ static void _resetScriptState(void) { } _soundQueueDrain(finished); _global.effectsVolume = _defaultVolume(AUDIO_MAX_VOLUME); - MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); + for (x = 0; x < EFFECT_TRACKS; x++) { + _effectApplyGain(x); + } // Music starts as loud as the sound effects, on its own scale and with its own gain, so // musicSetVolume and soundSetVolume never move each other. _global.musicVolume = _defaultVolume(MUSIC_MAX_VOLUME); @@ -7239,6 +7265,7 @@ static void _spriteDestroy(SpriteT *sprite) { HASH_DEL(_global.spriteList, sprite); _spriteFreeSurface(sprite); SDL_DestroySurface(sprite->rotatedFrame); + SDL_DestroySurface(sprite->flippedFrame); if (sprite->animation != NULL) { // Frames belong to the animation. IMG_FreeAnimation(sprite->animation); @@ -7249,6 +7276,23 @@ static void _spriteDestroy(SpriteT *sprite) { } +// The mirrored copy of one sheet frame that spriteDrawFrame draws while the sprite is flipped, kept +// from one draw to the next so a sprite standing on a frame is not mirrored again every frame. +static SDL_Surface *_spriteFlippedFrame(SpriteT *sprite, SDL_Surface *source, int32_t frame) { + if ((sprite->flippedFrame == NULL) || (sprite->flippedIndex != frame)) { + SDL_DestroySurface(sprite->flippedFrame); + sprite->flippedFrame = SDL_DuplicateSurface(source); + if (sprite->flippedFrame == NULL) { + utilDie("Unable to mirror frame %d of sprite %d.", frame, sprite->id); + } + SDL_FlipSurface(sprite->flippedFrame, sprite->flip); + sprite->flippedIndex = frame; + } + + return sprite->flippedFrame; +} + + // Releases the drawn surface if it is a transformed copy. Animation frames are never freed here. static void _spriteFreeSurface(SpriteT *sprite) { if (sprite->surfaceOwned) { @@ -7324,11 +7368,11 @@ static SpriteT *_spriteNew(lua_State *L, const char *method) { // Rebuilds the drawn surface after a frame, angle, scale, or quality change. static void _spriteRebuildSurface(SpriteT *sprite) { _spriteFreeSurface(sprite); - if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) { + if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0) && (sprite->flip == SDL_FLIP_NONE)) { // Untransformed sprites draw straight from the original. sprite->surface = sprite->originalSurface; } else { - sprite->surface = rotoZoomSurface(sprite->originalSurface, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); + sprite->surface = _spriteTransform(sprite, sprite->originalSurface); if (sprite->surface == NULL) { utilDie("Unable to transform sprite %d.", sprite->id); } @@ -7350,6 +7394,25 @@ static void _spriteRegister(SpriteT *sprite) { } +// A fresh copy of source turned by the sprite's angle, scaled by its scale, and then mirrored as +// its flip says, or NULL when the copy could not be made. Mirroring after turning is what lets +// spriteFlip change the mirror on a turned frame without turning it again. +static SDL_Surface *_spriteTransform(SpriteT *sprite, SDL_Surface *source) { + SDL_Surface *copy = NULL; + + if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) { + copy = SDL_DuplicateSurface(source); + } else { + copy = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); + } + if ((copy != NULL) && (sprite->flip != SDL_FLIP_NONE)) { + SDL_FlipSurface(copy, sprite->flip); + } + + return copy; +} + + // The disc audio suffix a run and a reload start from: --altaudio's, or the game's own audio. static const char *_startAudioSuffix(void) { return (_global.conf->audioSuffix != NULL) ? _global.conf->audioSuffix : ""; @@ -8082,7 +8145,6 @@ static void _updateSounds(void) { Vec3T relative; MIX_Point3D point; EffectT *effect = NULL; - float baseGain = _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME); float distance = 0.0f; float gain = 0.0f; bool useNode = false; @@ -8103,7 +8165,7 @@ static void _updateSounds(void) { if (!nodeValid(effect->node)) { effect->positioned = false; MIX_SetTrack3DPosition(_effectTracks[x], NULL); - MIX_SetTrackGain(_effectTracks[x], baseGain); + _effectApplyGain(x); continue; } world = nodeGetWorldPosition(effect->node); @@ -8117,11 +8179,10 @@ static void _updateSounds(void) { } gain = _effectGain(effect, distance); // The mixer only hears about changes. - if ((gain == effect->gain) && (baseGain == effect->baseGain) && (relative.x == effect->relative[0]) && (relative.y == effect->relative[1]) && (relative.z == effect->relative[2])) { + if ((gain == effect->gain) && (relative.x == effect->relative[0]) && (relative.y == effect->relative[1]) && (relative.z == effect->relative[2])) { continue; } effect->gain = gain; - effect->baseGain = baseGain; effect->relative[0] = relative.x; effect->relative[1] = relative.y; effect->relative[2] = relative.z; @@ -8129,7 +8190,7 @@ static void _updateSounds(void) { point.y = relative.y; point.z = relative.z; MIX_SetTrack3DPosition(_effectTracks[x], &point); - MIX_SetTrackGain(_effectTracks[x], baseGain * gain); + _effectApplyGain(x); } } @@ -10404,6 +10465,14 @@ static int32_t apiMaterialSetNormalMap(lua_State *L) { } +// materialSetOccluder(material, bool) +static int32_t apiMaterialSetOccluder(lua_State *L) { + _argCheck(L, "materialSetOccluder", 2, 2); + materialSetOccluder(_argMaterial(L, "materialSetOccluder", 1), _argBoolean(L, "materialSetOccluder", 2)); + return 0; +} + + // materialSetOcclusionMap(material[, image[, strength]]) occlusion in R; nil clears; strength defaults to 1. static int32_t apiMaterialSetOcclusionMap(lua_State *L) { return _materialSetMap(L, "materialSetOcclusionMap", MAP_OCCLUSION, true); @@ -13727,9 +13796,10 @@ static int32_t apiSoundPlay(lua_State *L) { SoundT *sound = NULL; int32_t channel = SOUND_CHANNEL_NONE; int32_t loops = 0; + int32_t volume = AUDIO_MAX_VOLUME; SDL_PropertiesID options; - _argCheck(L, "soundPlay", 1, 2); + _argCheck(L, "soundPlay", 1, 3); sound = _argSound(L, "soundPlay", 1); if (lua_gettop(L) >= 2) { loops = _argInteger(L, "soundPlay", 2); @@ -13737,11 +13807,18 @@ static int32_t apiSoundPlay(lua_State *L) { _luaDie(L, "soundPlay", "Loops must be -1 (forever), 0 (once) or a repeat count: %d", loops); } } + if (lua_gettop(L) >= 3) { + volume = _argInteger(L, "soundPlay", 3); + if ((volume < 0) || (volume > AUDIO_MAX_VOLUME)) { + _luaDie(L, "soundPlay", "Invalid sound volume value: %d", volume); + } + } channel = _effectTrackFree(); if (channel >= 0) { _effectReset(channel); + _effects[channel].volume = _mixerGain(volume, AUDIO_MAX_VOLUME); + _effectApplyGain(channel); MIX_SetTrackAudio(_effectTracks[channel], sound->audio); - MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); options = SDL_CreateProperties(); SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, loops); if (!MIX_PlayTrack(_effectTracks[channel], options)) { @@ -13750,7 +13827,7 @@ static int32_t apiSoundPlay(lua_State *L) { } SDL_DestroyProperties(options); } - _luaTrace(L, "soundPlay", "%d %d loops %d", sound->id, channel, loops); + _luaTrace(L, "soundPlay", "%d %d loops %d volume %d", sound->id, channel, loops, volume); lua_pushinteger(L, channel); return 1; @@ -13773,6 +13850,26 @@ static int32_t apiSoundResume(lua_State *L) { } + +// soundSetChannelVolume(channel, volume): one channel's own volume, 0 to AUDIO_MAX_VOLUME, under +// the master. soundPlay starts every channel at full. +static int32_t apiSoundSetChannelVolume(lua_State *L) { + int32_t channel = 0; + int32_t volume = 0; + + _argCheck(L, "soundSetChannelVolume", 2, 2); + channel = _argChannel(L, "soundSetChannelVolume", 1); + volume = _argInteger(L, "soundSetChannelVolume", 2); + if ((volume < 0) || (volume > AUDIO_MAX_VOLUME)) { + _luaDie(L, "soundSetChannelVolume", "Invalid sound volume value: %d", volume); + } + _effects[channel].volume = _mixerGain(volume, AUDIO_MAX_VOLUME); + _effectApplyGain(channel); + _luaTrace(L, "soundSetChannelVolume", "%d %d", channel, volume); + + return 0; +} + // soundSetListener([node]): positioned sounds are heard from this node; none means the scene camera static int32_t apiSoundSetListener(lua_State *L) { _argCheck(L, "soundSetListener", 0, 1); @@ -13797,7 +13894,7 @@ static int32_t apiSoundSetNode(lua_State *L) { effect->node = LISTENER_CAMERA; effect->positioned = false; MIX_SetTrack3DPosition(_effectTracks[channel], NULL); - MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); + _effectApplyGain(channel); } return 0; } @@ -13818,7 +13915,7 @@ static int32_t apiSoundSetPan(lua_State *L) { gains.left = SDL_min(1.0f, 1.0f - pan); gains.right = SDL_min(1.0f, 1.0f + pan); MIX_SetTrackStereo(_effectTracks[channel], &gains); - MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); + _effectApplyGain(channel); return 0; } @@ -13862,6 +13959,7 @@ static int32_t apiSoundSetRange(lua_State *L) { // soundSetVolume(volume) 0 to AUDIO_MAX_VOLUME, applied to every effect channel. static int32_t apiSoundSetVolume(lua_State *L) { int32_t volume = 0; + int32_t x = 0; _argCheck(L, "soundSetVolume", 1, 1); volume = _argInteger(L, "soundSetVolume", 1); @@ -13869,7 +13967,9 @@ static int32_t apiSoundSetVolume(lua_State *L) { _luaDie(L, "soundSetVolume", "Invalid sound volume value: %d", volume); } _global.effectsVolume = volume; - MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); + for (x = 0; x < EFFECT_TRACKS; x++) { + _effectApplyGain(x); + } _luaTrace(L, "soundSetVolume", "%d", _global.effectsVolume); return 0; @@ -14022,6 +14122,9 @@ static int32_t apiSpriteDrawFrame(lua_State *L) { } source = sprite->animation->frames[frame - 1]; } + if (sprite->flip != SDL_FLIP_NONE) { + source = _spriteFlippedFrame(sprite, source, (sprite->animation != NULL) ? frame : 1); + } dest.w = source->w; dest.h = source->h; if (n == 4) { @@ -14110,6 +14213,36 @@ static int32_t apiSpriteDrawRotatedFrame(lua_State *L) { } + +// spriteFlip(id, horizontal, vertical) Mirrors the sprite: every draw of it, of its frames, and of +// its turned frame, until it is called again with false, false. The mirror is applied after the +// turn, so a turned frame is just mirrored the other way rather than turned again. +static int32_t apiSpriteFlip(lua_State *L) { + SpriteT *sprite = NULL; + SDL_FlipMode flip = SDL_FLIP_NONE; + + _argCheck(L, "spriteFlip", 3, 3); + sprite = _argSprite(L, "spriteFlip", 1); + if (_argBoolean(L, "spriteFlip", 2)) { + flip |= SDL_FLIP_HORIZONTAL; + } + if (_argBoolean(L, "spriteFlip", 3)) { + flip |= SDL_FLIP_VERTICAL; + } + if (flip != sprite->flip) { + if (sprite->rotatedFrame != NULL) { + SDL_FlipSurface(sprite->rotatedFrame, sprite->flip ^ flip); + } + SDL_DestroySurface(sprite->flippedFrame); + sprite->flippedFrame = NULL; + sprite->flip = flip; + _spriteRebuildSurface(sprite); + } + _luaTrace(L, "spriteFlip", "%d %d", sprite->id, flip); + + return 0; +} + // height = spriteFrameHeight(id) Hypseus extension. One frame's height before scaling and rotation; the image's, for a still. static int32_t apiSpriteFrameHeight(lua_State *L) { SpriteT *sprite = NULL; @@ -14462,7 +14595,7 @@ static int32_t apiSpriteRotateFrame(lua_State *L) { source = sprite->animation->frames[frame - 1]; } SDL_DestroySurface(sprite->rotatedFrame); - sprite->rotatedFrame = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); + sprite->rotatedFrame = _spriteTransform(sprite, source); if (sprite->rotatedFrame == NULL) { _luaDie(L, "spriteRotateFrame", "Unable to turn frame %d of sprite %d.", frame, sprite->id); } diff --git a/testScripts/author/adventure2d.game b/testScripts/author/adventure2d.game new file mode 100644 index 000000000..26079decc --- /dev/null +++ b/testScripts/author/adventure2d.game @@ -0,0 +1,140 @@ +-- Genre one of milestone 3: a point-and-click adventure in a painted room, Sierra and LucasArts +-- in one table. Walk areas that become a navigation mesh, hotspots, a verb line, a text parser +-- as well, an inventory, a dialogue with a choice that changes the world, a cut-scene, two rooms +-- that remember themselves, and a save. The "painting" is a box, since no art is needed. +return { + title = "Author test: adventure", + size = { w = 720, h = 480 }, + layers = { { kind = "overlay" }, + { kind = "parser", prompt = "> ", + words = { look = { "look", "examine", "l" }, take = { "take", "get", "pick" }, use = { "use" }, open = { "open" }, talk = { "talk", "speak" }, + key = { "key" }, door = { "door", "gate" }, guard = { "guard", "man" } }, + ignore = { "the", "a", "an", "at", "to", "with", "on", "up" } } }, + vars = { score = 0, allowed = false }, + verbs = { "walk", "look", "take", "use", "open", "talk" }, + + kinds = { + 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" }, + behaviours = { { kind = "walker", speed = 160 } } }, + door = { look = { kind = "box", w = 60, h = 120, r = 90, g = 60, b = 40, anchor = "feet" }, + behaviours = { { kind = "hotspot", name = "door", walkX = 600, walkY = 330 } } }, + key = { look = { kind = "box", w = 16, h = 8, r = 255, g = 220, b = 60 }, + behaviours = { { kind = "hotspot", name = "key", walkX = 200, walkY = 400 } } }, + guard = { look = { kind = "box", w = 34, h = 80, r = 80, g = 120, b = 200, anchor = "feet" }, + behaviours = { { kind = "hotspot", name = "guard", walkX = 480, walkY = 360 } } }, + pillar = { look = { kind = "box", w = 40, h = 200, r = 60, g = 60, b = 80, anchor = "feet" } }, + marker = { look = { kind = "none" } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + rooms = { + { name = "hall", + depthSort = true, + 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 } + } }, + { 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 } + } } + }, + + dialogues = { + guard = { + start = "hello", + nodes = { + hello = { who = "Guard", text = "Nobody passes.", + choices = { { text = "Why not?", next = "why" }, + { text = "Fine." } } }, + why = { who = "Guard", text = "The door is locked, and I have no key.", + choices = { { text = "I have a key.", when = 'has("key")', act = { { "setVar", name = "allowed", value = true }, { "addScore", amount = 1 } }, next = "ok" }, + { text = "I see." } } }, + ok = { who = "Guard", text = "Then go ahead." } + } + } + }, + + rules = { + { note = "A click on the floor walks there", + on = "pressed", switch = "SWITCH_BUTTON3", interrupt = true, + act = { { "walkToPointer", entity = "hero", player = 1 } } }, + + { note = "Look at anything", + on = "verb", verb = "look", + act = { { "say", text = '"It is a " .. event.target .. "."', seconds = 1 } } }, + + { note = "Take the key", + on = "verb", verb = "take", target = "key", + act = { { "walkToHotspot", entity = "hero", target = "self" }, + { "give", item = "key" }, + { "addScore", amount = 1 }, + { "destroy", entity = "self" } } }, + + { note = "Talk to the guard", + on = "verb", verb = "talk", target = "guard", + act = { { "walkToHotspot", entity = "hero", target = "self" }, + { "face", entity = "hero", target = "self" }, + { "talk", dialogue = "guard" } } }, + + { note = "Open the door while the guard objects", + on = "verb", verb = "open", target = "door", + when = { { "test", expr = "not allowed" } }, + act = { { "say", text = '"The guard shakes his head."', seconds = 1 } } }, + + { note = "Open the door: the cut-scene", + on = "verb", verb = "open", target = "door", room = "hall", controls = false, + when = { { "test", expr = "allowed" } }, + act = { { "walkToHotspot", entity = "hero", target = "self" }, + { "say", text = '"The door swings open."', seconds = 1 }, + { "fade", seconds = 0.5, out = true }, + { "goTo", room = "yard", entity = "hero", at = "arrival" }, + { "fade", seconds = 0.5, out = false }, + { "addScore", amount = 1 }, + { "setVar", name = "outside", value = true } } }, + + { note = "Use the key on the door", + on = "verb", verb = "use", item = "key", target = "door", + act = { { "say", text = '"It is not that kind of lock."', seconds = 1 } } }, + + { note = "Anything else", + on = "verb", + act = { { "say", text = '"That does not work."', seconds = 1 } } }, + + { note = "Typed: look at the door", + on = "said", verb = "look", noun = "door", + act = { { "say", text = '"A heavy door."', seconds = 1 } } }, + + { note = "Typed: a word the game does not know", + on = "said", + when = { { "test", expr = "event.unknown != nil" } }, + act = { { "say", text = '"I do not know the word \\"" .. event.unknown .. "\\"."', seconds = 1 } } }, + + { note = "Typed: anything else", + on = "said", + act = { { "say", text = '"You cannot do that here."', seconds = 1 } } }, + + { note = "The verb keys", + on = "pressed", key = "L", + act = { { "setVerb", verb = "look" } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '(sentence or "") .. " score " .. score' } } } + } +} diff --git a/testScripts/author/adventure3d.game b/testScripts/author/adventure3d.game new file mode 100644 index 000000000..ddb4da9a6 --- /dev/null +++ b/testScripts/author/adventure3d.game @@ -0,0 +1,53 @@ +-- 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 +-- change between a painting and a scene is the point. +local base = dofile("testScripts/author/adventure2d.game") + +return { + title = "Author test: adventure in 3D", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 50 }, { kind = "overlay" }, base.layers[2] }, + vars = { score = 0, allowed = false }, + verbs = base.verbs, + dialogues = base.dialogues, + rules = base.rules, + + kinds = { + 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" } } }, + door = { look = { kind = "mesh", shape = "box", w = 1.2, h = 2.4, d = 0.2, r = 90, g = 60, b = 40 }, + behaviours = { { kind = "hotspot", name = "door", walkX = 5, walkY = 0, walkZ = -3 } } }, + key = { look = { kind = "mesh", shape = "sphere", w = 0.3, r = 255, g = 220, b = 60 }, + behaviours = { { kind = "hotspot", name = "key", walkX = -3, walkY = 0, walkZ = 1 } } }, + guard = { look = { kind = "mesh", shape = "cylinder", w = 0.7, h = 1.8, r = 80, g = 120, b = 200 }, + behaviours = { { kind = "hotspot", name = "guard", walkX = 3.2, walkY = 0, walkZ = -1.5 } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 0.5, lookZ = -1 } } }, + marker = { look = { kind = "none" } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + rooms = { + { 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 } + } }, + { 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 } + } } + } +} diff --git a/testScripts/author/bowling.game b/testScripts/author/bowling.game new file mode 100644 index 000000000..2da9513b0 --- /dev/null +++ b/testScripts/author/bowling.game @@ -0,0 +1,55 @@ +-- Sports with a ball, from the catalogue: a ball on a body shoved down a lane by a charged +-- press, pins that are bodies, a gutter that is a trigger, and pins counted by how far they fell. +return { + title = "Author test: bowling", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 100, g = 100, b = 110 }, fov = 50 }, { kind = "overlay" } }, + vars = { score = 0, down = 0, thrown = false }, + + kinds = { + 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 }, + behaviours = { { kind = "body", mass = 1.5, friction = 0.5 } } }, + gutter = { look = { kind = "none", w = 3, h = 1, d = 2 }, behaviours = { { kind = "trigger" } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "ball", x = 0, y = 2.5, z = 5, lookY = 0.2, lag = 0.3 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "The throw", + on = "pressed", key = "SPACE", + when = { { "test", expr = "not thrown" } }, + act = { { "setVar", name = "thrown", value = true }, { "push", entity = "ball", x = 0, y = 0, z = -60 } } }, + + { note = "A pin has fallen when it leans past the point of no return", + on = "frame", each = "pin", + when = { { "test", expr = "not self.counted and self.y < 0.25" } }, + act = { { "setVar", entity = "self", name = "counted", value = true }, { "addVar", name = "down", amount = 1 }, { "addScore", amount = 10 } } }, + + { note = "The ball reaches the end", + on = "enter", a = "gutter", b = "ball", + act = { { "setVar", name = "done", value = true } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"pins down " .. down .. " score " .. score' } } } + } +} diff --git a/testScripts/author/breakout.game b/testScripts/author/breakout.game new file mode 100644 index 000000000..33e33afd4 --- /dev/null +++ b/testScripts/author/breakout.game @@ -0,0 +1,55 @@ +-- Arcade physics, from the catalogue: a ball that bounces, bricks that break, a paddle the keys +-- move, and a life lost when the ball is missed. world2d with no gravity. +return { + title = "Author test: breakout", + size = { w = 720, h = 480 }, + layers = { { kind = "world2d", gravity = 0 } }, + vars = { score = 0, lives = 3, broken = 0 }, + + kinds = { + 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 }, + behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, { kind = "mover", speed = 400, clamp = true }, { kind = "solid", moving = true } } }, + ball = { look = { kind = "box", w = 14, h = 14, r = 255, g = 220, b = 80 }, + behaviours = { { kind = "body", shape = "sphere", mass = 1, bounce = 1, friction = 0 } } }, + brick = { look = { kind = "box", w = 60, h = 20, r = 220, g = 90, b = 90 }, behaviours = { { kind = "solid" } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "Serve", + on = "roomStart", + act = { { "push", entity = "ball", x = 120, y = -380 } } }, + + { note = "A brick breaks", + on = "collision", a = "ball", b = "brick", + act = { { "destroy", entity = "other" }, { "addScore", amount = 10 }, { "addVar", name = "broken", amount = 1 } } }, + + { note = "The ball is missed", + on = "frame", + when = { { "test", expr = "ball.y > 500" } }, + act = { { "addVar", name = "lives", amount = -1 }, { "moveTo", entity = "ball", x = 360, y = 380 }, { "push", entity = "ball", x = -100, y = -380 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " lives " .. lives' } } } + } +} diff --git a/testScripts/author/fmv.game b/testScripts/author/fmv.game new file mode 100644 index 000000000..f9aa594a9 --- /dev/null +++ b/testScripts/author/fmv.game @@ -0,0 +1,74 @@ +-- Genre one of milestone 5: branching full-motion video, Dragon's Lair in a table. Windows on +-- the disc that want a move, the disc sent to a success or a failure clip, lives, credits and a +-- continue, the arcade score panel, and the score sent to the board at the end. The menu video +-- stands in for the disc. +return { + title = "Author test: branching video", + 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 = { + 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 } } + }, + + 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 } + }, + tracks = { + { name = "moves", key = "frame", + branches = { { from = 120, to = 160, move = "RIGHT", success = 200, fail = 340 }, + { from = 220, to = 260, move = "UP", success = 300, fail = 340 } } } + } } + }, + + rules = { + { note = "A window opens: say what it wants", + on = "branchOpen", + act = { { "setText", entity = "prompt", text = '"MOVE " .. event.move' } } }, + + { note = "Made it", + on = "branchTaken", + act = { { "addScore", amount = 500 }, + { "addVar", name = "taken", amount = 1 }, + { "setText", entity = "prompt", text = '""' } } }, + + { note = "Missed it: a life gone", + on = "branchMissed", + act = { { "addVar", name = "lives", amount = -1 }, + { "addVar", name = "missed", amount = 1 }, + { "flash", r = 255, g = 40, b = 40, seconds = 0.3 }, + { "setText", entity = "prompt", text = '""' } } }, + + { note = "The death clip is over: back to the cavern, or game over", + on = "frameReached", frame = 380, + when = { { "test", expr = "lives > 0" } }, + act = { { "discTo", frame = 100 } } }, + + { note = "Out of lives", + on = "frameReached", frame = 380, + when = { { "test", expr = "lives <= 0" } }, + act = { { "submitScore", board = "default" }, + { "gameOver" } } }, + + { note = "A coin", + on = "pressed", switch = "SWITCH_COIN1", + act = { { "credit" } } }, + + { note = "Continue", + on = "pressed", switch = "SWITCH_START1", + when = { { "test", expr = "credits > 0 and lives <= 0" } }, + act = { { "addVar", name = "credits", amount = -1 }, + { "setVar", name = "lives", value = 3 }, + { "restart" } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " lives " .. lives .. " credits " .. credits' } } } + } +} diff --git a/testScripts/author/fps.game b/testScripts/author/fps.game new file mode 100644 index 000000000..49c5c5c93 --- /dev/null +++ b/testScripts/author/fps.game @@ -0,0 +1,81 @@ +-- A first-person shooter, from the catalogue: a character the keys move relative to a camera on +-- its eyes that the mouse turns, a gun that fires from the middle of the picture, and enemies +-- that walk the navigation mesh after the hero and bite when they reach it. +return { + title = "Author test: first person", + size = { w = 720, h = 480 }, + 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 = { + 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 }, + behaviours = { { kind = "keys", player = 1, left = "A", right = "D", up = "W", down = "S" }, + { kind = "character", speed = 4, jump = 5, radius = 0.3, height = 1.7 } } }, + eyes = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "first", target = "hero", eye = 1.5 } } }, + gun = { look = { kind = "none" }, vars = { ammo = 30 }, behaviours = { { kind = "gun", player = 1, ammo = 30, reload = "key", aim = "centre" } } }, + zombie = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 }, + vars = { health = 2 }, + behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 1.5 }, + { kind = "walker", speed = 2.5, radius = 0.4, follow = "hero", every = 0.4, stopAt = 1.6 }, + { kind = "animator", idle = "Survey", walk = "Walk", attack = "Run", fade = 0.2 }, + { kind = "target", zones = { { name = "head", bone = "b_Head_05", w = 0.5, h = 0.5, d = 0.5 } } }, + { kind = "timer", name = "bite", after = 1.2, start = false } } }, + hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "zombie", every = 2, max = 4, total = 6, points = { { x = -8, y = 0, z = -12 }, { x = 8, y = 0, z = -12 }, { x = 0, y = 0, z = -16 } } } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + rooms = { + { 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 } + } } + }, + + rules = { + { note = "A zombie walks from the moment it is made", + on = "spawn", kind = "zombie", + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "A shot lands", + on = "hit", kind = "zombie", + act = { { "damage", entity = "self", amount = 1 }, { "addScore", amount = 50 } } }, + + { note = "A headshot", + on = "hit", kind = "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", + 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", + 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 } } }, + + { note = "Reload on R", + on = "pressed", key = "R", + act = { { "reload", entity = "gun1" } } }, + + { note = "Out of health", + on = "frame", + when = { { "test", expr = "health <= 0" }, { "once", tag = "over", scope = "game" } }, + act = { { "gameOver" } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health .. " ammo " .. gun1.ammo .. " zombies " .. count("zombie")' } } } + } +} diff --git a/testScripts/author/gunvideo.game b/testScripts/author/gunvideo.game new file mode 100644 index 000000000..acbfd8743 --- /dev/null +++ b/testScripts/author/gunvideo.game @@ -0,0 +1,61 @@ +-- Genre two of milestone 1: a light gun game over video. Hitboxes keyed to disc frames, a gun +-- that hits or misses, ammo and a reload, a prompt at a frame, and the disc as the picture -- Mad +-- Dog McCree in a table. The boxes follow nothing in the menu video; they are where the test +-- expects them. +return { + title = "Author test: gun over video", + players = 1, + layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, + vars = { score = 0, misses = 0 }, + + kinds = { + gun = { look = { kind = "none" }, + vars = { ammo = 3 }, + behaviours = { { kind = "gun", player = 1, ammo = 3, reload = "offscreen" } } }, + bandit = { look = { kind = "none" }, + vars = { health = 1 }, + behaviours = { { kind = "hitbox", track = "bandit" }, { kind = "health", max = 1 } } }, + hostage = { look = { kind = "none" }, + behaviours = { { kind = "hitbox", track = "hostage" } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + }, + 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 } } }, + { name = "hostage", key = "frame", boxes = { { at = 100, x = 560, y = 240, w = 50, h = 80 }, { at = 300, x = 560, y = 240, w = 50, h = 80 } } } + } } + }, + + rules = { + { note = "The bandit is shot", + on = "hit", kind = "bandit", + act = { { "addScore", amount = 100 }, + { "damage", entity = "self", amount = 1 } } }, + + { note = "The hostage is shot", + on = "hit", kind = "hostage", + act = { { "addScore", amount = -200 }, + { "flash", r = 255, g = 40, b = 40, seconds = 0.3 } } }, + + { note = "A miss on the picture", + on = "miss", + when = { { "test", expr = "not event.offscreen" } }, + act = { { "addVar", name = "misses", amount = 1 } } }, + + { note = "Draw!", + on = "frameReached", frame = 120, + act = { { "say", text = '"DRAW!"', seconds = 1 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " ammo " .. gun1.ammo' } } } + } +} diff --git a/testScripts/author/hud.rml b/testScripts/author/hud.rml new file mode 100644 index 000000000..30844c87f --- /dev/null +++ b/testScripts/author/hud.rml @@ -0,0 +1,17 @@ + + + hud + + + + +
0
+
3
+
+ +
diff --git a/testScripts/author/maze.game b/testScripts/author/maze.game new file mode 100644 index 000000000..622dc7366 --- /dev/null +++ b/testScripts/author/maze.game @@ -0,0 +1,48 @@ +-- A maze, from the catalogue: corridors that are a walk area, a hero sent by clicks, dots that +-- are eaten on touch, and a ghost that follows the hero across the mesh. +return { + title = "Author test: maze", + size = { w = 720, h = 480 }, + layers = { { kind = "overlay" } }, + vars = { score = 0, eaten = 0 }, + + kinds = { + 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 } } }, + dot = { look = { kind = "box", w = 8, h = 8, r = 255, g = 255, b = 255 } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + rooms = { + { 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 } + } } + }, + + rules = { + { note = "A click sends the hero", + on = "pressed", switch = "SWITCH_BUTTON3", interrupt = true, + act = { { "walkToPointer", entity = "hero", player = 1 } } }, + + { note = "A dot is eaten", + on = "collision", a = "hero", b = "dot", + act = { { "destroy", entity = "other" }, { "addScore", amount = 10 }, { "addVar", name = "eaten", amount = 1 } } }, + + { note = "Caught", + on = "collision", a = "ghost", b = "hero", + act = { { "setVar", name = "caught", value = true } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " eaten " .. eaten' } } } + } +} diff --git a/testScripts/author/painted.game b/testScripts/author/painted.game new file mode 100644 index 000000000..d02da4db2 --- /dev/null +++ b/testScripts/author/painted.game @@ -0,0 +1,43 @@ +-- A painted room in 3D, the shape Grim Fandango has: the backdrop is a picture on a flat mesh, a +-- pillar that is only in the picture is an occluder -- an invisible box that hides what walks +-- behind it -- and a character crosses the room behind it and comes out the other side. +return { + title = "Author test: painted room", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 120, g = 120, b = 130 }, sun = false, fov = 50 }, { kind = "overlay" } }, + vars = { score = 0 }, + + kinds = { + 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" } } }, + hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.012, clip = "Walk", w = 0.8, h = 1, d = 1.5 }, + behaviours = { { kind = "seek", target = "far", speed = 2.2, stopAt = 0.3 } } }, + marker = { look = { kind = "none" } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 1.2, lookZ = -4 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "Across the room", + on = "arrived", kind = "hero", + act = { { "setVar", name = "across", value = true } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"the hero is at " .. floor(hero.x * 10) / 10' } } } + } +} diff --git a/testScripts/author/platformer.game b/testScripts/author/platformer.game index 607809e8e..de517718c 100644 --- a/testScripts/author/platformer.game +++ b/testScripts/author/platformer.game @@ -1,52 +1,62 @@ -- A game description: data, not code. Forge/AuthorCompile.singe turns it into a Singe game. -- --- Genre one of two. This one uses a single world2d layer and shares nothing with the QTE beside --- it -- no disc, no branching, no time windows -- which is the point: if the core can express --- both, it is not secretly a platformer engine or secretly a laserdisc engine. +-- Genre one of the two the first slice was built on. A single world2d room, a character that runs +-- and jumps, a prize, and a readout. It shares nothing with the QTE beside it -- no disc, no +-- branching, no time windows -- which is the point: if the core can express both, it is not +-- secretly a platformer engine or secretly a laserdisc engine. return { - title = "Author test: platformer", + title = "Author test: platformer", + layers = { { kind = "world2d", gravity = 1500 } }, + vars = { score = 0 }, - layers = { - { kind = "world2d", gravity = 1500 } + kinds = { + 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 } } }, + hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, + readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } }, - entities = { - { id = "ground", x = 360, y = 440, look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, - behaviours = { { kind = "solid" } } }, - { id = "ledge", x = 520, y = 330, look = { kind = "box", w = 180, h = 20, r = 60, g = 70, b = 90 }, - behaviours = { { kind = "solid" } } }, - { id = "prize", x = 520, y = 300, look = { kind = "box", w = 18, h = 18, r = 255, g = 200, b = 60 }, - behaviours = { { kind = "drift", vx = 0, vy = 0 } } }, - { id = "hero", x = 120, y = 380, look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, - behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, - { id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } + 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 } + } } }, rules = { { note = "Run left", + on = "frame", when = { { "keyHeld", key = "LEFT" } }, act = { { "run", entity = "hero", direction = -1 } } }, { note = "Run right", + on = "frame", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } }, { note = "Jump, but only with ground underfoot", + on = "frame", when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, act = { { "jump", entity = "hero" } } }, - { note = "Take the prize once", - when = { { "touching", entity = "hero", other = "prize" }, { "once", tag = "prize" } }, + { note = "Take the prize", + on = "collision", a = "hero", b = "prize", act = { { "addScore", amount = 100 }, - { "show", entity = "prize", visible = false }, - { "setFlag", flag = "won", value = true } } }, + { "destroy", entity = "other" }, + { "setVar", name = "won", value = true } } }, { note = "Fallen off the world: back to the start", + on = "frame", when = { { "below", entity = "hero", y = 520 } }, act = { { "moveTo", entity = "hero", x = 120, y = 380 } } }, { note = "The readout, every frame", - when = {}, - act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } } + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score' } } } } } diff --git a/testScripts/author/platformer3d.game b/testScripts/author/platformer3d.game new file mode 100644 index 000000000..78977c548 --- /dev/null +++ b/testScripts/author/platformer3d.game @@ -0,0 +1,65 @@ +-- Genre two of milestone 2: a 3D platformer. A character on the engine's controller, a camera +-- that follows, floors and ledges, a prize that is a trigger, and a fall that puts the hero back. +-- Nothing in it is shared with the rail shooter but the scene itself. +return { + title = "Author test: 3D platformer", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 55 }, { kind = "overlay" } }, + vars = { score = 0 }, + + kinds = { + 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" } } }, + 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 = "LEFT", right = "RIGHT", up = "UP", down = "DOWN" }, + { kind = "character", speed = 4, jump = 6.5, radius = 0.3, height = 0.9 }, + { kind = "animator", idle = "Survey", walk = "Walk" } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "hero", x = 0, y = 3, z = 7, lookY = 0.6, lag = 0.2 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "Walking sets the state the animator plays", + on = "frame", each = "hero", + when = { { "test", expr = "self.dx != 0 or self.dy != 0" } }, + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "Standing still", + on = "frame", each = "hero", + when = { { "test", expr = "self.dx == 0 and self.dy == 0" } }, + act = { { "setState", entity = "self", state = "idle" } } }, + + { note = "Jump, with ground underfoot", + on = "frame", + when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, + act = { { "jump", entity = "hero" } } }, + + { note = "The prize is taken", + on = "enter", a = "prize", b = "hero", + act = { { "destroy", entity = "self" }, + { "addScore", amount = 100 }, + { "setVar", name = "won", value = true } } }, + + { note = "Fallen off the world: back to the start", + on = "frame", + when = { { "test", expr = "hero.y < -5" } }, + act = { { "moveTo", entity = "hero", x = -4, y = 0.5, z = 0 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score' } } } + } +} diff --git a/testScripts/author/pool.game b/testScripts/author/pool.game new file mode 100644 index 000000000..9ea42f694 --- /dev/null +++ b/testScripts/author/pool.game @@ -0,0 +1,42 @@ +-- The long tail, in 3D: water a body floats in, a boat pushed by its propeller, and a soft +-- body -- the pieces a boat game or a physics toy needs. +return { + title = "Author test: the pool", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 90, g = 100, b = 120 }, fov = 55 }, { kind = "overlay" } }, + vars = { score = 0 }, + + kinds = { + 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 } } }, + boat = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.4, d = 2.6, r = 200, g = 170, b = 90 }, + behaviours = { { kind = "body", mass = 120, buoyancy = 3 }, { kind = "vehicle", type = "boat", mass = 120, thrust = 600 } } }, + balloon = { look = { kind = "mesh", shape = "sphere", w = 1, r = 240, g = 220, b = 80 }, behaviours = { { kind = "soft", type = "body", pressure = 2, mass = 0.6 } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 1, lookZ = 0 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "The boat runs its propeller", + on = "frame", each = "boat", + act = { { "setVar", entity = "self", name = "dy", value = -1 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"ball y " .. floor(ball.y * 10) / 10 .. " boat z " .. floor(boat.z * 10) / 10' } } } + } +} diff --git a/testScripts/author/qte.game b/testScripts/author/qte.game index 52a0bef90..38a6a8cc8 100644 --- a/testScripts/author/qte.game +++ b/testScripts/author/qte.game @@ -1,37 +1,47 @@ --- Genre two of two: a quick-time event over video. No physics, no character, no gravity -- the --- only layers are the disc and a flat overlay, and the rules talk about disc frames and windows. +-- Genre two of the first slice: a quick-time event over video. No physics, no character, no +-- gravity -- the only layers are the disc and a flat overlay, and the rules talk about disc frames +-- and windows. return { - title = "Author test: QTE over video", + title = "Author test: QTE over video", + layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 180 }, { kind = "overlay" } }, + vars = { score = 0 }, - layers = { - { kind = "disc", start = 180 }, - { kind = "overlay" } + kinds = { + 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 } } }, - entities = { - { id = "prompt", x = 250, y = 60, look = { kind = "text", text = "", r = 255, g = 220, b = 80 } }, - { id = "verdict", x = 250, y = 100, look = { kind = "text", text = "", r = 160, g = 255, b = 180 } }, - { id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } + 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 } + } } }, rules = { { note = "The window is open: ask for the button", + on = "frame", when = { { "discBetween", from = 200, to = 260 } }, act = { { "setText", entity = "prompt", text = '"PRESS!"' } } }, { note = "Answered in time, once", + on = "frame", when = { { "discBetween", from = 200, to = 260 }, { "switchHeld", switch = "SWITCH_BUTTON1" }, { "once", tag = "hit" } }, - act = { { "setFlag", flag = "hit", value = true }, + act = { { "setVar", name = "hit", value = true }, { "addScore", amount = 250 }, { "setText", entity = "verdict", text = '"HIT"' } } }, { note = "The window has closed without an answer", - when = { { "discBetween", from = 260, to = 320 }, { "once", tag = "judged" } }, + on = "frameReached", frame = 260, + when = { { "test", expr = "not hit" } }, act = { { "setText", entity = "prompt", text = '""' }, - { "lua", code = 'if not AUTHOR_FLAG["hit"] then authorSetText("verdict", "MISS") end' } } }, + { "setText", entity = "verdict", text = '"MISS"' } } }, { note = "The readout, every frame", - when = {}, - act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } } + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score' } } } } } diff --git a/testScripts/author/racing.game b/testScripts/author/racing.game new file mode 100644 index 000000000..0bf34f7cd --- /dev/null +++ b/testScripts/author/racing.game @@ -0,0 +1,66 @@ +-- Genre one of milestone 4: racing. A car on the engine's vehicle physics driven by the keys, +-- a rival that drives a track of points on its own, checkpoints that are triggers, laps counted +-- by rules, and a camera that follows. +return { + title = "Author test: racing", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 90, g = 90, b = 110 }, fov = 60 }, { kind = "overlay" } }, + vars = { score = 0, laps = 0, next = 1 }, + + kinds = { + 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" }, + { kind = "vehicle", type = "car", torque = 900, mass = 800 } } }, + rival = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.6, d = 3.2, r = 60, g = 90, b = 220 }, + behaviours = { { kind = "vehicle", type = "car", torque = 700, mass = 800 }, + { kind = "racer", track = "lap", throttle = 0.5, reach = 4 } } }, + gate = { look = { kind = "none", w = 10, h = 3, d = 1 }, vars = { index = 1 }, behaviours = { { kind = "trigger" } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "car", x = 0, y = 4, z = 9, lookY = 0.5, lag = 0.3 } } }, + bar = { look = { kind = "mesh", shape = "box", w = 4, h = 0.3, d = 0.3, r = 200, g = 200, b = 60 }, + behaviours = { { kind = "body", mass = 20 }, { kind = "joint", type = "hinge", ax = -8, ay = 2, az = -20, dx = 0, dy = 0, dz = 1 } } }, + hover = { look = { kind = "mesh", shape = "box", w = 1.2, h = 0.4, d = 2, r = 60, g = 220, b = 200 }, + behaviours = { { kind = "body", mass = 100, friction = 0 }, { kind = "thrust", force = 900, turn = 0.5 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + }, + 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 } } } + } } + }, + + rules = { + { note = "Through the next gate", + on = "enter", a = "gate", b = "car", + when = { { "test", expr = "self.index == next" } }, + act = { { "addVar", name = "next", amount = 1 }, + { "addScore", amount = 10 } } }, + + { note = "Both gates passed: a lap", + on = "frame", + when = { { "test", expr = "next > 2" } }, + act = { { "addVar", name = "laps", amount = 1 }, + { "setVar", name = "next", value = 1 } } }, + + { note = "The hovercraft pushes forward on its own", + on = "frame", each = "hover", + act = { { "setVar", entity = "self", name = "dy", value = -1 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"speed " .. floor(car.speed) .. " next gate " .. next .. " laps " .. laps' } } } + } +} diff --git a/testScripts/author/railshooter.game b/testScripts/author/railshooter.game new file mode 100644 index 000000000..e77b63db7 --- /dev/null +++ b/testScripts/author/railshooter.game @@ -0,0 +1,116 @@ +-- Genre one of milestone 2: a rail shooter, House of the Dead in a table. A camera on a rail +-- that stops at encounters, zombies (the Fox, standing in) that spawn at each stop, walk to the +-- camera, and bite on a timer unless they are killed, a gun that hits what the pointer is over -- +-- and which part -- ragdolls, and a level end. +return { + title = "Author test: rail shooter", + players = 1, + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 70, g = 70, b = 90 }, fog = { r = 12, g = 12, b = 22, near = 25, far = 60 }, fov = 60 }, + { kind = "overlay" } }, + vars = { score = 0, misses = 0, health = 3, bites = 0, stops = 0 }, + + kinds = { + 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" } } }, + 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 }, + behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 2 }, + { kind = "seek", target = "camera", speed = 1.6, stopAt = 2.2 }, + { kind = "animator", idle = "Survey", walk = "Walk", attack = "Run", fade = 0.2 }, + { kind = "target", zones = { { name = "head", bone = "b_Head_05", w = 0.5, h = 0.5, d = 0.5 } } }, + { kind = "timer", name = "bite", after = 1.5, start = false } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + }, + 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 } } } } + } } + }, + + rules = { + { 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 } } }, + + { 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 } } }, + + { note = "A zombie starts walking the moment it is made", + on = "spawn", kind = "zombie", + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "A shot lands", + on = "hit", kind = "zombie", + act = { { "damage", entity = "self", amount = 1 }, + { "addScore", amount = 100 } } }, + + { note = "A headshot lands harder", + on = "hit", kind = "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", + 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", + 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.3 }, + { "shake", amount = 0.3, seconds = 0.4 }, + { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, + + { note = "A miss on the picture", + on = "miss", + when = { { "test", expr = "not event.offscreen" } }, + act = { { "addVar", name = "misses", amount = 1 } } }, + + { note = "The stop is clear: move on", + on = "frame", + when = { { "waiting", entity = "camera" }, { "test", expr = 'count("zombie") == 0' } }, + act = { { "pathNext", entity = "camera" } } }, + + { note = "Out of health", + on = "frame", + when = { { "test", expr = "health <= 0" }, { "once", tag = "over", scope = "game" } }, + act = { { "say", text = '"YOU ARE DEAD"', seconds = 2 }, { "gameOver" } } }, + + { note = "The end of the rail", + on = "railEnd", + act = { { "setVar", name = "done", value = true }, + { "say", text = '"STAGE CLEAR"', seconds = 2 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health .. " ammo " .. gun1.ammo' } } } + } +} diff --git a/testScripts/author/rhythm.game b/testScripts/author/rhythm.game new file mode 100644 index 000000000..18126aac1 --- /dev/null +++ b/testScripts/author/rhythm.game @@ -0,0 +1,50 @@ +-- A rhythm game, from the catalogue: a track plays, notes are windows in time, and a press inside +-- one scores while one outside is a miss. The notes are a track of points keyed by time, so the +-- editor's timeline lays them out. +return { + title = "Author test: rhythm", + size = { w = 720, h = 480 }, + layers = { { kind = "overlay" }, { kind = "music", file = "testScripts/tune.mp3", volume = 60 } }, + vars = { score = 0, hits = 0, misses = 0, early = 0 }, + + kinds = { + 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 }, + behaviours = { { kind = "drift", vx = 0, vy = 200 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + }, + tracks = { + { name = "notes", key = "time", points = { { at = 1.0 }, { at = 2.0 }, { at = 2.5 }, { at = 3.5 } } } + } } + }, + + rules = { + { 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 } } }, + + { note = "A press on a note at the marker", + on = "pressed", key = "SPACE", each = "note", + when = { { "test", expr = "abs(self.y - 400) < 24" } }, + act = { { "destroy", entity = "self" }, { "addScore", amount = 100 }, { "addVar", name = "hits", amount = 1 } } }, + + { note = "A note that falls past the marker is a miss", + on = "frame", each = "note", + when = { { "test", expr = "self.y > 440" } }, + act = { { "destroy", entity = "self" }, { "addVar", name = "misses", amount = 1 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " hits " .. hits .. " misses " .. misses' } } } + } +} diff --git a/testScripts/author/shmup.game b/testScripts/author/shmup.game new file mode 100644 index 000000000..38999c759 --- /dev/null +++ b/testScripts/author/shmup.game @@ -0,0 +1,77 @@ +-- Genre one of milestone 1: a shoot-em-up. Kinds spawned by the dozen, bullets that collide, +-- health, a spawner, a HUD document, sound, and a game over -- none of which the first slice could +-- say. Everything is a box, so the description needs no art. +return { + title = "Author test: shoot-em-up", + layers = { { kind = "overlay" }, { kind = "hud", document = "testScripts/author/hud.rml", bind = { score = "score", lives = "lives" } } }, + vars = { score = 0, lives = 3 }, + + kinds = { + 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" }, + { kind = "mover", speed = 260, clamp = true }, + { kind = "shooter", spawns = "shot", rate = 0.15, offsetY = -14 }, + { kind = "health", max = 1, keep = true }, + { kind = "sound", hit = "testScripts/crackle.wav" } } }, + shot = { look = { kind = "box", w = 4, h = 10, r = 255, g = 255, b = 160 }, + behaviours = { { kind = "projectile", vx = 0, vy = -520, life = 3 } } }, + raider = { look = { kind = "box", w = 26, h = 18, r = 240, g = 80, b = 80 }, + vars = { health = 2 }, + behaviours = { { kind = "drift", vx = 0, vy = 90 }, + { kind = "health", max = 2 }, + { kind = "sound", death = "testScripts/crackle.wav" } } }, + hive = { look = { kind = "none" }, + behaviours = { { kind = "spawner", spawns = "raider", every = 0.6, max = 6, total = 12, + points = { { x = 100, y = -20 }, { x = 300, y = -20 }, { x = 500, y = -20 }, { x = 640, y = -20 } } } } } + }, + + rooms = { + { name = "space", + entities = { + { kind = "ship", id = "ship", x = 360, y = 420 }, + { kind = "hive", id = "hive", x = 360, y = -20 } + } } + }, + + rules = { + { note = "A shot lands on a raider", + on = "collision", a = "shot", b = "raider", + act = { { "destroy", entity = "self" }, + { "damage", entity = "other", amount = 1 }, + { "addScore", amount = 10 } } }, + + { note = "A raider dies", + on = "death", kind = "raider", + act = { { "emit", entity = "self", count = 24, r = 255, g = 120, b = 60 }, + { "addScore", amount = 50 } } }, + + { note = "A raider rams the ship", + on = "collision", a = "raider", b = "ship", + act = { { "destroy", entity = "self" }, + { "damage", entity = "other", amount = 1 } } }, + + { note = "The ship is lost", + on = "death", kind = "ship", + act = { { "addVar", name = "lives", amount = -1 }, + { "flash", r = 255, g = 60, b = 60, seconds = 0.3 }, + { "setVar", entity = "self", name = "health", value = 1 }, + { "moveTo", entity = "self", x = 360, y = 420 } } }, + + { note = "Out of lives", + on = "frame", + when = { { "test", expr = "lives <= 0" } }, + act = { { "gameOver" } } }, + + { note = "A raider slips past the bottom", + on = "frame", each = "raider", + when = { { "test", expr = "self.y > 500" } }, + act = { { "destroy", entity = "self" } } }, + + { note = "The wave is done", + on = "frame", + when = { { "test", expr = 'count("raider") == 0 and hive.made >= 12' }, { "once", tag = "wave", scope = "game" } }, + act = { { "setVar", name = "cleared", value = true }, + { "say", text = '"WAVE CLEAR"', seconds = 2 } } } + } +} diff --git a/testScripts/author/space.game b/testScripts/author/space.game new file mode 100644 index 000000000..8c5dde8c1 --- /dev/null +++ b/testScripts/author/space.game @@ -0,0 +1,48 @@ +-- A space shooter, from the catalogue: a ship on a body pushed by thrust and turned by the keys, +-- bolts it fires, rocks that come at it, and a camera behind. No gravity. +return { + title = "Author test: space", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", gravity = 0, ambient = { r = 40, g = 40, b = 70 }, fov = 65 }, { kind = "overlay" } }, + vars = { score = 0, rocks = 0 }, + + kinds = { + 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 }, + { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN", fire = "SPACE" }, + { kind = "shooter", spawns = "bolt", rate = 0.2, offsetY = 0 } } }, + bolt = { look = { kind = "mesh", shape = "sphere", w = 0.3, r = 255, g = 240, b = 120 }, + behaviours = { { kind = "projectile", vx = 0, vy = 0, vz = -30, life = 3 } } }, + rock = { look = { kind = "mesh", shape = "sphere", w = 2, r = 130, g = 110, b = 90 }, + vars = { health = 1 }, + behaviours = { { kind = "projectile", vx = 0, vy = 0, vz = 6, life = 20 }, { kind = "health", max = 1 } } }, + belt = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "rock", every = 0.8, max = 8, total = 12, points = { { x = -4, y = 0, z = -60 }, { x = 0, y = 0, z = -60 }, { x = 4, y = 0, z = -60 }, { x = 2, y = 1, z = -60 } } } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "ship", x = 0, y = 3, z = 8, lookY = 0, lag = 0.2 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "A bolt meets a rock", + on = "collision", a = "bolt", b = "rock", + act = { { "destroy", entity = "self" }, { "damage", entity = "other", amount = 1 } } }, + + { note = "A rock is broken", + on = "death", kind = "rock", + act = { { "addScore", amount = 25 }, { "addVar", name = "rocks", amount = 1 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " rocks " .. rocks' } } } + } +} diff --git a/testScripts/author/tail.game b/testScripts/author/tail.game new file mode 100644 index 000000000..829c99eb6 --- /dev/null +++ b/testScripts/author/tail.game @@ -0,0 +1,73 @@ +-- The long tail, in 2D: a sprite sheet stepped by state, a map of tiles, and MIDI notes as an +-- event -- the pieces a rhythm game or a maze game needs. +return { + title = "Author test: the long tail", + size = { w = 720, h = 480 }, + layers = { { kind = "overlay" } }, + vars = { score = 0, notes = 0 }, + + kinds = { + 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" }, + { kind = "mover", speed = 120, clamp = true } } }, + -- The same sheet, drawn facing right only: facing left mirrors it, and rz turns a frame. + mirrored = { look = { kind = "sprite", file = "testScripts/strip.png", frames = 5, anchor = "feet" }, + behaviours = { { kind = "frames", fps = 10, states = "idle=1-1, walk=3-5" }, + { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, + { kind = "mover", speed = 120, clamp = true }, + { kind = "sound", hit = "testScripts/crackle.wav", volume = 30 } } }, + floor = { look = { kind = "grid", file = "testScripts/checker.png", tile = 32, columns = 2, map = "1 2 1 2 1; 2 1 2 1 2; 3 4 3 4 3" } }, + spinner = { look = { kind = "sprite", file = "testScripts/checker.png" }, behaviours = { { kind = "spin", rate = 90 } } }, + fire = { look = { kind = "particles", r = 255, g = 140, b = 40, rate = 60, size = 10, speed = 80, life = 0.8 } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + 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 } + } } + }, + + rules = { + { note = "Moving means walking", + on = "frame", each = "runner", + when = { { "test", expr = "self.dx != 0" } }, + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "Still means idle", + on = "frame", each = "runner", + when = { { "test", expr = "self.dx == 0" } }, + act = { { "setState", entity = "self", state = "idle" } } }, + + { note = "The mirrored one walks the same", + on = "frame", each = "mirrored", + when = { { "test", expr = "self.dx != 0" } }, + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "Middle C is a quiet click as well", + on = "midi", pitch = 60, + act = { { "playSound", file = "testScripts/crackle.wav", volume = 40 } } }, + + { note = "Middle C scores", + on = "midi", pitch = 60, + act = { { "addScore", amount = 10 }, + { "addVar", name = "notes", amount = 1 } } }, + + { note = "Any other note is counted", + on = "midi", + when = { { "test", expr = "event.pitch != 60" } }, + act = { { "addVar", name = "notes", amount = 1 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"frame " .. runner.frame .. " " .. runner.state .. " score " .. score .. " notes " .. notes' } } } + } +} diff --git a/testScripts/author/thirdperson.game b/testScripts/author/thirdperson.game new file mode 100644 index 000000000..d31b9c0c9 --- /dev/null +++ b/testScripts/author/thirdperson.game @@ -0,0 +1,76 @@ +-- Third-person action, from the catalogue: a character the keys move relative to a camera that +-- orbits it under the mouse, enemies that walk the mesh after it and bite, and a swing of the +-- sword on a key at whatever is in reach. +return { + title = "Author test: third person", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 60 }, { kind = "overlay" } }, + vars = { score = 0, health = 5, swings = 0 }, + + kinds = { + 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" }, + { kind = "character", speed = 4, jump = 5, radius = 0.3, height = 0.9 }, + { kind = "animator", idle = "Survey", walk = "Walk" } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "orbit", target = "hero", y = 1.2, z = 6, lookY = 0.6 } } }, + enemy = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 }, + vars = { health = 2 }, + behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 1 }, + { kind = "walker", speed = 2, radius = 0.4, follow = "hero", every = 0.4, stopAt = 1.6 }, + { 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 } } + }, + + 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 } + } } + }, + + rules = { + { note = "An enemy walks from the moment it is made", + on = "spawn", kind = "enemy", + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "Moving means walking", + on = "frame", each = "hero", + when = { { "test", expr = "self.dx != 0 or self.dy != 0" } }, + act = { { "setState", entity = "self", state = "walk" } } }, + + { note = "Still means idle", + on = "frame", each = "hero", + when = { { "test", expr = "self.dx == 0 and self.dy == 0" } }, + act = { { "setState", entity = "self", state = "idle" } } }, + + { note = "A swing of the sword hits whatever is in reach", + on = "pressed", key = "SPACE", each = "enemy", + when = { { "test", expr = 'distance(self, "hero") < 2.2' } }, + act = { { "damage", entity = "self", amount = 1 }, { "addVar", name = "swings", amount = 1 } } }, + + { note = "A kill", + on = "death", kind = "enemy", + act = { { "addScore", amount = 100 } } }, + + { note = "It reaches the hero and rears up", + on = "arrived", kind = "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", + when = { { "inState", entity = "self", state = "attack" } }, + act = { { "addVar", name = "health", amount = -1 }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health' } } } + } +} diff --git a/testScripts/author/towerdefence.game b/testScripts/author/towerdefence.game new file mode 100644 index 000000000..09199b633 --- /dev/null +++ b/testScripts/author/towerdefence.game @@ -0,0 +1,70 @@ +-- Genre two of milestone 4: tower defence. Creeps that walk a lane on the navigation mesh in +-- waves, turrets placed with a click on the floor that fire at the nearest creep in range, +-- projectiles that collide, gold and lives. Nothing here is shared with the racing game but the +-- scene. +return { + title = "Author test: tower defence", + size = { w = 720, h = 480 }, + layers = { { kind = "scene3d", ambient = { r = 80, g = 90, b = 80 }, fov = 55 }, { kind = "overlay" } }, + vars = { score = 0, gold = 120, lives = 5, killed = 0 }, + + kinds = { + 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 }, + behaviours = { { kind = "walker", speed = 2.5, radius = 0.3 }, + { kind = "patrol", points = { { x = -10, y = 0, z = -6 }, { x = 10, y = 0, z = -6 }, { x = 10, y = 0, z = 6 }, { x = -10, y = 0, z = 6 } } }, + { kind = "health", max = 3 } } }, + hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "creep", every = 1.2, max = 8, total = 10, points = { { x = -12, y = 0, z = -6 } } } } }, + turret = { look = { kind = "mesh", shape = "cylinder", w = 0.8, h = 1.2, r = 70, g = 90, b = 220 }, + behaviours = { { kind = "turret", targets = "creep", range = 6, rate = 0.5, spawns = "bolt", speed = 14 } } }, + bolt = { look = { kind = "mesh", shape = "sphere", w = 0.25, r = 255, g = 230, b = 80 }, + behaviours = { { kind = "projectile", life = 2 } } }, + camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 0, lookZ = 0 } } }, + readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } + }, + + rooms = { + { 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 } + } } + }, + + rules = { + { 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 }, + { "addVar", name = "gold", amount = -50 } } }, + + { note = "A bolt lands", + on = "collision", a = "bolt", b = "creep", + act = { { "destroy", entity = "self" }, + { "damage", entity = "other", amount = 1 } } }, + + { note = "A creep dies", + on = "death", kind = "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", + act = { { "destroy", entity = "self" }, + { "addVar", name = "lives", amount = -1 } } }, + + { note = "Out of lives", + on = "frame", + when = { { "test", expr = "lives <= 0" }, { "once", tag = "over", scope = "game" } }, + act = { { "gameOver" } } }, + + { note = "The readout, every frame", + on = "frame", + act = { { "setText", entity = "readout", text = '"gold " .. gold .. " lives " .. lives .. " killed " .. killed .. " creeps " .. count("creep")' } } } + } +} diff --git a/testScripts/scene52.singe b/testScripts/scene52.singe index 06002f6fa..fb91778f2 100644 --- a/testScripts/scene52.singe +++ b/testScripts/scene52.singe @@ -1,7 +1,8 @@ --- Authoring tools (PLAN section 58): compiles both sample descriptions and plays the platformer. +-- Forge (FORGE.md): compiles all four sample descriptions and plays the platformer. -- --- The two descriptions share nothing -- one is physics with a character, the other is disc frames --- and time windows -- so compiling both here is the test that the core has no genre baked into it. +-- 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 +-- 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") @@ -13,10 +14,16 @@ fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -built.platformer = authorBuild("testScripts/author/platformer.game", out .. "platformer.singe") -built.qte = authorBuild("testScripts/author/qte.game", out .. "qte.singe") -debugPrint("AUTHOR built " .. built.platformer) -debugPrint("AUTHOR built " .. built.qte) +for _, name in ipairs({ "platformer", "qte", "shmup", "gunvideo" }) do + local game = authorLoad("testScripts/author/" .. name .. ".game") + local problems = authorCheck(game) + + built[name] = authorBuild("testScripts/author/" .. name .. ".game", out .. name .. ".singe") + debugPrint("AUTHOR built " .. built[name] .. " with " .. #problems .. " problems") + for _, problem in ipairs(problems) do + debugPrint("AUTHOR FAIL " .. name .. ": " .. problem) + end +end -- Both must at least load as Lua; a compiler that emits something unparseable is the failure this -- catches earliest, and it costs nothing to check both even though only one is played. @@ -75,7 +82,7 @@ function onOverlayUpdate() -- The engine's own text over the authored game, so a screenshot says what it is looking at. colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored platformer, %.1fs, score %d", t, AUTHOR_SCORE)) + fontPrint(12, 450, string.format("authored platformer, %.1fs, score %d", t, AUTHOR_VARS.score)) -- Four shots at the moments that matter, found by watching rather than by timing: the start, -- the run, the jump, and the frame after the prize was taken. @@ -83,11 +90,11 @@ function onOverlayUpdate() shots = shots + 1 singeScreenshot() end - if AUTHOR_FLAG["won"] and won == 0 then + if AUTHOR_VARS.won and won == 0 then won = t end if (won > 0 and t > won + 0.6) or (t > GIVE_UP) then - debugPrint("AUTHOR RESULT score=" .. AUTHOR_SCORE .. " flagWon=" .. tostring(AUTHOR_FLAG["won"]) .. " after=" .. string.format("%.1fs", t)) + debugPrint("AUTHOR RESULT score=" .. AUTHOR_VARS.score .. " flagWon=" .. tostring(AUTHOR_VARS.won) .. " after=" .. string.format("%.1fs", t)) singeQuit() end diff --git a/testScripts/scene53.singe b/testScripts/scene53.singe index 91a4600bf..b08668b3b 100644 --- a/testScripts/scene53.singe +++ b/testScripts/scene53.singe @@ -1,4 +1,4 @@ --- Authoring tools (PLAN section 58), genre two: the QTE description, played over the disc. +-- Forge (FORGE.md), genre two of the first slice: the QTE description, played over the disc. -- -- Scene 52 plays the platformer. This one shares no vocabulary with it beyond the three nouns -- -- no physics, no character, no gravity, and every rule is about disc frames -- which is what makes @@ -43,7 +43,7 @@ function onOverlayUpdate() singeScreenshot() end if disc >= 310 or frames > 1200 then - debugPrint("AUTHOR QTE RESULT score=" .. AUTHOR_SCORE .. " hit=" .. tostring(AUTHOR_FLAG["hit"])) + debugPrint("AUTHOR QTE RESULT score=" .. AUTHOR_VARS.score .. " hit=" .. tostring(AUTHOR_VARS.hit)) singeQuit() end diff --git a/testScripts/scene54.singe b/testScripts/scene54.singe index 508581fa4..96605500a 100644 --- a/testScripts/scene54.singe +++ b/testScripts/scene54.singe @@ -1,10 +1,10 @@ --- Forge (PLAN section 58): opened on a real description, driven, saved, and the --- result compiled and played. +-- Forge (FORGE.md): opened on a real description, driven, saved, and the result compiled and +-- played. -- -- Driven by calling the editor's own functions rather than by a pointer, the way the menu's -- sanitizer scene drives the menu: what is being tested is that moving something in the editor -- reaches the description, survives a save, and changes the game that comes out. The pointer path --- itself was settled by the spike in PLAN section 58 and needs a real X server to exercise. +-- itself was settled by the spike in FORGE.md section 1 and needs a real X server to exercise. -- As a library: the tail of Forge.singe that launches it is skipped, because this scene -- drives it rather than being it. FORGE_LIBRARY = true @@ -47,8 +47,8 @@ local function step() if index == nil then debugPrint("FORGE FAIL nothing under the prize's own position") else - before = FORGE.game.entities[index].x - debugPrint("FORGE picked " .. FORGE.game.entities[index].id .. " at x=" .. before) + before = forgeRoom().entities[index].x + debugPrint("FORGE picked " .. forgeRoom().entities[index].id .. " at x=" .. before) forgePress(520, 300) end @@ -59,7 +59,7 @@ local function step() elseif stage == 3 then forgeRelease() - local moved = FORGE.game.entities[forgePick(300, 300)] + local moved = forgeRoom().entities[forgePick(300, 300)] debugPrint("FORGE moved to x=" .. moved.x .. " dirty=" .. tostring(FORGE.dirty)) @@ -69,7 +69,7 @@ local function step() local reloaded = authorLoad(work) local found = nil - for _, e in ipairs(reloaded.entities) do + for _, e in ipairs(reloaded.rooms[1].entities) do if e.id == "prize" then found = e.x end @@ -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", x = 300') then + if text:find('id = "prize", kind = "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") @@ -135,7 +135,7 @@ local function step() elseif stage == 9 then -- And now the same press reaches the hero, which is the whole point of a movable panel. forgePress(120, 380) - if FORGE.selected ~= nil and FORGE.game.entities[FORGE.selected].id == "hero" then + if FORGE.selected ~= nil and forgeRoom().entities[FORGE.selected].id == "hero" then debugPrint("FORGE the hero is reachable once the panel has moved") else debugPrint("FORGE FAIL the hero is still unreachable") diff --git a/testScripts/scene55.singe b/testScripts/scene55.singe index e825248ee..444424282 100644 --- a/testScripts/scene55.singe +++ b/testScripts/scene55.singe @@ -1,4 +1,4 @@ --- The event sheet (PLAN section 58): rules edited, not just entities moved. +-- The event sheet (FORGE.md): rules edited, not just entities moved. -- -- Driven by keys, which is how the editor is meant to be used and how the bundled menu's document -- has always been driven. What is asserted is that an edit made through the rule editor reaches @@ -50,7 +50,8 @@ end local function step() if stage == 1 then - key(SCANCODE.TAB) + key(SCANCODE.TAB) -- entities to kinds, + key(SCANCODE.TAB) -- kinds to rules. debugPrint("RULES mode is now " .. FORGE.mode .. ", rules " .. #FORGE.game.rules) elseif stage == 2 then @@ -77,7 +78,7 @@ local function step() if after == before then debugPrint("RULES FAIL the new rule did not reach the compiled game") - elseif after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') and after:find('authorOnGround%("hero"%)') and after:find('authorJump%("hero"%)') then + elseif after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') and after:find('authorOnGround%(authorEntity%("hero"%)%)') and after:find('authorJump%(authorEntity%("hero"%)%)') then debugPrint("RULES the compiled game carries the new rule") else debugPrint("RULES FAIL the compiled game is missing part of the new rule") @@ -116,9 +117,10 @@ local function step() elseif stage == 7 then -- A number has to come back a number: "100" and 100 compile to different source, so a -- retyped value that turned into a string would stop the description round-tripping. - -- Rule 4 is "Take the prize once"; its third part is addScore, whose value is amount. + -- Rule 4 is "Take the prize", on a collision; its first part is addScore, whose value is + -- amount. FORGE.rule = 4 - FORGE.part = 3 + FORGE.part = 1 key(SCANCODE.RETURN) for c in string.gmatch("250", ".") do key(SCANCODE.A, string.byte(c)) @@ -132,15 +134,15 @@ local function step() debugPrint("RULES FAIL amount is " .. type(amount) .. " " .. tostring(amount)) end - -- And ESC has to put back what was there. - FORGE.part = 1 + -- And ESC has to put back what was there: the second part is destroy, on other. + FORGE.part = 2 key(SCANCODE.RETURN) key(SCANCODE.A, string.byte("Z")) key(SCANCODE.ESCAPE) - if FORGE.game.rules[4].when[1].entity == "hero" then + if FORGE.game.rules[4].act[2].entity == "other" then debugPrint("RULES escape put the value back") else - debugPrint("RULES FAIL escape left " .. tostring(FORGE.game.rules[4].when[1].entity)) + debugPrint("RULES FAIL escape left " .. tostring(FORGE.game.rules[4].act[2].entity)) end elseif stage == 8 then diff --git a/testScripts/scene56.singe b/testScripts/scene56.singe index 826b06d59..8ec459c3a 100644 --- a/testScripts/scene56.singe +++ b/testScripts/scene56.singe @@ -1,4 +1,4 @@ --- A Forge release is standalone (PLAN section 58). +-- A Forge release is standalone (FORGE.md). -- -- Building it is half the test; the half that matters is that the result runs somewhere Forge has -- never been. The runtime a compiled game loads is Singe/Author.singe, which whatever Singe the diff --git a/testScripts/scene57.singe b/testScripts/scene57.singe index c9836d792..beefb5f82 100644 --- a/testScripts/scene57.singe +++ b/testScripts/scene57.singe @@ -1,4 +1,4 @@ --- Forge as a game (PLAN section 58): the chooser, the engine callbacks, and the editing that +-- Forge as a game (FORGE.md): the chooser, the engine callbacks, and the editing that -- needs no pointer -- entities added, renamed and deleted, a rule built from the pickers, undo. -- -- Unlike scene54 to 56 this does not set FORGE_LIBRARY: Forge installs its own callbacks, and @@ -40,31 +40,33 @@ local function step() debugPrint("GAME FAIL ENTER on the chooser opened nothing") else opened = FORGE.path - debugPrint("GAME opened " .. opened .. " with " .. #FORGE.game.entities .. " entities") + debugPrint("GAME opened " .. opened .. " with " .. #forgeRoom().entities .. " entities") end elseif stage == 2 then - -- Add an entity, name it, and give it a look by typing the fields ENTER walks. - local count = #FORGE.game.entities + -- Add an entity, name it, and make it a hero by typing the fields ENTER walks. + local count = #forgeRoom().entities key(SCANCODE.A) - if #FORGE.game.entities ~= count + 1 then + if #forgeRoom().entities ~= count + 1 then debugPrint("GAME FAIL A did not add an entity") end key(SCANCODE.RETURN) -- id typeIn("coin") + key(SCANCODE.RETURN) -- kind + typeIn("hero") key(SCANCODE.RETURN) -- x typeIn("300") key(SCANCODE.RETURN) -- y typeIn("200") - key(SCANCODE.RETURN) -- kind + key(SCANCODE.RETURN) -- z key(SCANCODE.ESCAPE) -- untouched - local coin = FORGE.game.entities[#FORGE.game.entities] + local coin = forgeRoom().entities[#forgeRoom().entities] - if coin.id == "coin" and coin.x == 300 and coin.y == 200 and coin.look.kind == "box" then - debugPrint("GAME typed an entity in: " .. coin.id .. " at " .. coin.x .. "," .. coin.y) + 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) else - debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y) .. " " .. tostring(coin.look.kind)) + debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " the " .. tostring(coin.kind) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y)) end elseif stage == 3 then @@ -77,7 +79,7 @@ local function step() key(SCANCODE.RETURN) typeIn("player") key(SCANCODE.ESCAPE) -- ESC puts it back: the hero keeps its name - if FORGE.game.entities[FORGE.selected].id ~= "hero" then + if forgeRoom().entities[FORGE.selected].id ~= "hero" then debugPrint("GAME FAIL escape did not put the name back") end key(SCANCODE.RETURN) @@ -93,18 +95,22 @@ local function step() end end end - if FORGE.game.entities[FORGE.selected].id == "player" and named == 3 then + if forgeRoom().entities[FORGE.selected].id == "player" and named == 3 then debugPrint("GAME renamed the hero and " .. named .. " actions followed") else - debugPrint("GAME FAIL rename: id " .. tostring(FORGE.game.entities[FORGE.selected].id) .. ", " .. named .. " actions renamed") + debugPrint("GAME FAIL rename: id " .. tostring(forgeRoom().entities[FORGE.selected].id) .. ", " .. named .. " actions renamed") end elseif stage == 4 then - -- A rule from the pickers: N, then C picks the first condition, T the first action. + -- A rule from the pickers: N, E picks an event, C a condition, T an action. local rules = #FORGE.game.rules - key(SCANCODE.TAB) + key(SCANCODE.TAB) -- kinds, + key(SCANCODE.TAB) -- rules. key(SCANCODE.N) + key(SCANCODE.E) + key(SCANCODE.DOWN) + key(SCANCODE.RETURN) key(SCANCODE.C) if FORGE.picker == nil then debugPrint("GAME FAIL C opened no picker") @@ -115,20 +121,21 @@ local function step() key(SCANCODE.RETURN) local rule = FORGE.game.rules[#FORGE.game.rules] - if #FORGE.game.rules == rules + 1 and #rule.when == 1 and #rule.act == 1 then - debugPrint("GAME built a rule from the pickers: when " .. rule.when[1][1] .. " then " .. rule.act[1][1]) + if #FORGE.game.rules == rules + 1 and #rule.when == 1 and #rule.act == 1 and rule.on ~= "frame" then + debugPrint("GAME built a rule from the pickers: on " .. rule.on .. " when " .. rule.when[1][1] .. " then " .. rule.act[1][1]) else debugPrint("GAME FAIL the picked rule has " .. #(rule.when or {}) .. " conditions and " .. #(rule.act or {}) .. " actions") end elseif stage == 5 then - -- Undo takes the action back, then the condition, then the rule; redo brings the rule - -- back, and a change by hand ends the redo history. + -- Undo takes the action back, then the condition, the event, then the rule; redo brings + -- the rule back, and a change by hand ends the redo history. local rules = #FORGE.game.rules key(SCANCODE.U) key(SCANCODE.U) key(SCANCODE.U) + key(SCANCODE.U) if #FORGE.game.rules == rules - 1 then debugPrint("GAME three undos took the rule back out") else @@ -152,15 +159,16 @@ local function step() elseif stage == 6 then -- Duplicate and delete in the entity list. - key(SCANCODE.TAB) - local count = #FORGE.game.entities + key(SCANCODE.TAB) -- tracks, + key(SCANCODE.TAB) -- entities. + local count = #forgeRoom().entities key(SCANCODE.D) key(SCANCODE.DELETE) - if #FORGE.game.entities == count then + if #forgeRoom().entities == count then debugPrint("GAME duplicate then delete leaves " .. count .. " entities") else - debugPrint("GAME FAIL duplicate then delete leaves " .. #FORGE.game.entities) + debugPrint("GAME FAIL duplicate then delete leaves " .. #forgeRoom().entities) end elseif stage == 7 then @@ -194,7 +202,7 @@ local function step() else local found = false - for _, entity in ipairs(FORGE.game.entities) do + for _, entity in ipairs(forgeRoom().entities) do if entity.id == "player" then found = true end diff --git a/testScripts/scene58.singe b/testScripts/scene58.singe new file mode 100644 index 000000000..c615403cd --- /dev/null +++ b/testScripts/scene58.singe @@ -0,0 +1,71 @@ +-- 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 +-- sequence (say waits), a flash, and a game over -- driven by what the game is doing. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +dofile(authorBuild("testScripts/author/shmup.game", singeGetDataPath() .. "shmup.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local maxRaiders = 0 +local maxShots = 0 +local reported = false +local GIVE_UP = 30 + + +function onOverlayUpdate() + local t = authorTime() + local ship = authorEntity("ship") + + frames = frames + 1 + -- Hold fire from the start and sweep left and right under the spawn points. + if frames == 2 then + authorKeyDown(0, SCANCODE.SPACE.value) + end + if ship then + local x = authorX(ship) + + if (x > 500) and authorKeyHeld(SCANCODE.RIGHT.value) then + authorKeyUp(0, SCANCODE.RIGHT.value) + authorKeyDown(0, SCANCODE.LEFT.value) + elseif (x < 120) and authorKeyHeld(SCANCODE.LEFT.value) then + authorKeyUp(0, SCANCODE.LEFT.value) + authorKeyDown(0, SCANCODE.RIGHT.value) + elseif not (authorKeyHeld(SCANCODE.LEFT.value) or authorKeyHeld(SCANCODE.RIGHT.value)) then + authorKeyDown(0, SCANCODE.RIGHT.value) + end + end + + local r = gameUpdate() + + maxRaiders = math.max(maxRaiders, authorCount("raider")) + maxShots = math.max(maxShots, authorCount("shot")) + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored shmup, %.1fs, raiders %d, shots %d, score %d, lives %d", t, authorCount("raider"), authorCount("shot"), AUTHOR_VARS.score, AUTHOR_VARS.lives)) + + if (shots == 0 and t > 1.5) or (shots == 1 and t > 4) or (shots == 2 and AUTHOR_VARS.cleared) then + shots = shots + 1 + singeScreenshot() + end + if (AUTHOR_VARS.cleared and t > 1 and not reported) or (t > GIVE_UP) then + reported = true + debugPrint(string.format("SHMUP RESULT score=%d lives=%d cleared=%s maxRaiders=%d maxShots=%d after=%.1fs", + AUTHOR_VARS.score, AUTHOR_VARS.lives, tostring(AUTHOR_VARS.cleared), maxRaiders, maxShots, t)) + if maxRaiders < 3 then + debugPrint("SHMUP FAIL the spawner never had three raiders up at once") + end + if AUTHOR_VARS.score <= 0 then + debugPrint("SHMUP FAIL nothing was ever hit") + end + singeQuit() + end + + return r +end diff --git a/testScripts/scene59.singe b/testScripts/scene59.singe new file mode 100644 index 000000000..2f1b28b33 --- /dev/null +++ b/testScripts/scene59.singe @@ -0,0 +1,93 @@ +-- Forge milestone 1, genre two: a light gun over video. Hitboxes keyed to disc frames and +-- interpolated between them, a gun that raises hit and miss, ammo that runs out, a reload by a +-- shot off the picture, a prompt at a frame, and a penalty for the hostage. The pointer is +-- injected through authorSetPointer, the door a test uses instead of a mouse. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/gunvideo.game", singeGetDataPath() .. "gunvideo.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local stage = 0 +local hits = 0 +local misses = 0 +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 + elseif name == "miss" then + misses = misses + 1 + end + return realEvent(name, event) +end + + +local function shoot(x, y, on) + authorSetPointer(1, x, y, on) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) +end + + +function onOverlayUpdate() + local disc = discGetFrame() + local box = authorTrackBox("bandit") + + frames = frames + 1 + -- Each shot waits for the disc to reach a frame, so the sequence is the same at any speed. + if (stage == 0) and (disc >= 130) and box then + -- Frame 130 of 120..220: the bandit box has moved a tenth of the way from x=100 to x=400. + debugPrint(string.format("GUN at frame %d the bandit box is at x=%.0f (interpolated from 100 toward 400)", disc, box.x)) + shoot(box.x + 10, box.y + 10, true) -- hit + stage = 1 + elseif (stage == 1) and (disc >= 150) then + shoot(20, 20, true) -- miss on the picture + shoot(box.x + 10, box.y + 10, true) -- hit (the bandit is dead; no target, so a miss) + stage = 2 + elseif (stage == 2) and (disc >= 170) then + -- Out of ammo now (3 shots taken): a shot on the picture does nothing, off it reloads. + local ammo = authorEntity("gun1").vars.ammo + + shoot(580, 260, true) -- hostage, but the gun is empty + debugPrint("GUN ammo before reload " .. ammo .. ", hostage hits so far " .. hostage) + shoot(-10, -10, false) -- reload + debugPrint("GUN ammo after reload " .. authorEntity("gun1").vars.ammo) + shoot(580, 260, true) -- hostage + stage = 3 + elseif (stage == 3) and (disc >= 320) then + stage = 4 + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, "authored gun game, disc frame " .. disc) + if box then + colorForeground(255, 80, 80, 255) + overlayBox(box.x, box.y, box.x + box.w, box.y + box.h) + end + if (shots == 0 and disc >= 125) or (shots == 1 and disc >= 160) or (shots == 2 and disc >= 200) then + shots = shots + 1 + singeScreenshot() + end + if stage == 4 or frames > 1500 then + debugPrint(string.format("GUN RESULT score=%d hits=%d misses=%d hostage=%d ammo=%d", AUTHOR_VARS.score, hits, misses, hostage, authorEntity("gun1").vars.ammo)) + if hits ~= 1 or hostage ~= 1 or misses ~= 2 then + debugPrint("GUN FAIL expected 1 hit, 2 misses, 1 hostage") + end + if authorTrackBoxAt({ boxes = { { at = 0, x = 0, y = 0, w = 10, h = 10 }, { at = 10, x = 100, y = 0, w = 10, h = 10 } } }, 5).x ~= 50 then + debugPrint("GUN FAIL interpolation") + end + singeQuit() + end + + return r +end diff --git a/testScripts/scene60.singe b/testScripts/scene60.singe new file mode 100644 index 000000000..e3ce911b8 --- /dev/null +++ b/testScripts/scene60.singe @@ -0,0 +1,148 @@ +-- The timeline (FORGE.md milestone 1): hitbox tracks keyed to disc frames, edited in Forge. The +-- cursor is stepped, keys are made at it, one is picked up off the canvas and dragged, one is +-- deleted, and the compiled game carries what was drawn. The description names its video, so the +-- editor has a frame to show under the cursor. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 15) +local out = singeGetDataPath() +local work = out .. "timeline.game" + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +local source = assert(io.open("testScripts/author/gunvideo.game", "r")) +local copy = assert(io.open(work, "w")) + +copy:write(source:read("a")) +source:close() +copy:close() + +if not forgeBegin(work) then + debugPrint("TIMELINE FAIL could not open " .. work) + singeQuit() +end + +local frames = 0 +local stage = 0 + + +local function key(code, character) + forgeKey(character or 0, code.value) +end + + +local function step() + local track = forgeRoom().tracks[1] + + if stage == 1 then + debugPrint("TIMELINE video loaded: " .. tostring(FORGE.video ~= nil) .. "; track '" .. track.name .. "' has " .. #track.boxes .. " keys") + key(SCANCODE.TAB) + key(SCANCODE.TAB) + key(SCANCODE.TAB) + if FORGE.mode ~= "tracks" then + debugPrint("TIMELINE FAIL three tabs did not reach the tracks: " .. FORGE.mode) + end + + elseif stage == 2 then + -- Step and leap the cursor to frame 170, halfway between the bandit's two keys. + for _ = 1, 17 do + key(SCANCODE.RIGHTBRACKET) + end + local box = forgeCursorBox() + + debugPrint(string.format("TIMELINE cursor at %d, the bandit box is at x=%.0f (halfway from 100 to 400)", FORGE.cursor, box.x)) + if math.abs(box.x - 250) > 1 then + debugPrint("TIMELINE FAIL interpolation at the cursor") + end + + elseif stage == 3 then + -- A key at the cursor takes the interpolated box, and the list stays sorted. + key(SCANCODE.K) + if #track.boxes == 3 and track.boxes[2].at == 170 and track.boxes[2].x == 250 then + debugPrint("TIMELINE a key at 170 went in between the two, at x=250") + else + debugPrint("TIMELINE FAIL after K the track has " .. #track.boxes .. " keys; second at " .. tostring(track.boxes[2] and track.boxes[2].at)) + end + + elseif stage == 4 then + -- Pick the box up off the canvas and drag it, the way a pointer would. + local box = forgeCursorBox() + + forgePress(box.x + 5, box.y + 5) + forgeDrag(box.x + 65, box.y + 25) + forgeRelease() + if track.boxes[2].x == 310 and track.boxes[2].y == 220 then + debugPrint("TIMELINE dragged the key to " .. track.boxes[2].x .. "," .. track.boxes[2].y) + else + debugPrint("TIMELINE FAIL the drag left the key at " .. tostring(track.boxes[2].x) .. "," .. tostring(track.boxes[2].y)) + end + + elseif stage == 5 then + -- Type the key's height, then step back a frame: the box between keys follows the new one. + key(SCANCODE.RETURN) -- at + key(SCANCODE.RETURN) -- x + key(SCANCODE.RETURN) -- y + key(SCANCODE.RETURN) -- w + key(SCANCODE.RETURN) -- h + for c in string.gmatch("40", ".") do + key(SCANCODE.A, string.byte(c)) + end + key(SCANCODE.RETURN) + key(SCANCODE.ESCAPE) + key(SCANCODE.COMMA) + local box = forgeCursorBox() + + debugPrint(string.format("TIMELINE key height typed to %d; at frame %d the box is %.0f tall", track.boxes[2].h, FORGE.cursor, box.h)) + + elseif stage == 6 then + -- Undo the typing and the drag, then delete the key. Undo puts a copy of the description + -- in place, so the track is looked up again afterwards. + key(SCANCODE.U) + key(SCANCODE.U) + track = forgeRoom().tracks[1] + if track.boxes[2].x ~= 250 then + debugPrint("TIMELINE FAIL undo did not bring the key back to x=250: " .. tostring(track.boxes[2].x)) + end + FORGE.key = 2 + key(SCANCODE.DELETE) + debugPrint("TIMELINE after two undos and a delete the track has " .. #track.boxes .. " keys") + + elseif stage == 7 then + -- And a new key in a new track reaches the compiled game. + key(SCANCODE.N) + key(SCANCODE.K) + key(SCANCODE.S) + local built = forgeBuild(out .. "timeline.singe") + local text = assert(io.open(built, "r")):read("a") + + if text:find('name = "track3"') and text:find("at = " .. FORGE.cursor) then + debugPrint("TIMELINE the compiled game carries the new track and its key") + else + debugPrint("TIMELINE FAIL the new track is missing from the compiled game") + end + debugPrint("TIMELINE RESULT done") + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 14 == 0 and stage < 8 then + stage = stage + 1 + step() + end + forgeDraw(nil, nil) + colorForeground(255, 255, 255, 255) + fontPrint(210, 8, "timeline, stage " .. stage) + if frames == 44 or frames == 72 or frames == 100 then + singeScreenshot() + end + if frames > 130 then + singeQuit() + end + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene61.singe b/testScripts/scene61.singe new file mode 100644 index 000000000..e790f55fb --- /dev/null +++ b/testScripts/scene61.singe @@ -0,0 +1,103 @@ +-- Forge milestone 2, genre one: the rail shooter. A camera on a rail that stops at encounters, +-- zombies that spawn there and walk to it, a gun whose ray says which part it hit, ragdolls, and +-- the rail moving on when the stop is clear. The pointer is aimed at each zombie's head through +-- sceneProject, which is what a light gun would do with a person behind it. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/railshooter.game", singeGetDataPath() .. "railshooter.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local hits = 0 +local heads = 0 +local kills = 0 +local stopped = 0 +local ended = false +local lastShot = 0 +local GIVE_UP = 60 + +local realEvent = authorEvent +function authorEvent(name, event) + if name == "hit" then + hits = hits + 1 + if event.part == "head" then + heads = heads + 1 + end + elseif name == "death" then + kills = kills + 1 + elseif name == "stopped" then + stopped = stopped + 1 + elseif name == "railEnd" then + ended = true + end + return realEvent(name, event) +end + + +local function fire(x, y, on) + authorSetPointer(1, x, y, on) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) +end + + +function onOverlayUpdate() + local t = authorTime() + local gun = authorEntity("gun1") + + frames = frames + 1 + -- Aim at the nearest living zombie's head and fire, no faster than a person would; reload + -- off the picture when empty. + if gun and (t - lastShot > 0.25) then + local nearest, best = nil, math.huge + + for _, zombie in ipairs(authorEach("zombie")) do + if zombie.vars.state ~= "dead" then + local far = authorDistance(zombie, authorEntity("camera")) + + if far < best then + nearest, best = zombie, far + end + end + end + if gun.vars.ammo <= 0 then + fire(-10, -10, false) + lastShot = t + elseif nearest then + local head = nodeFind("b_Head_05", nearest.model) + local hx, hy, hz = nodeGetWorldPosition(head) + local sx, sy, _, inFront = sceneProject(hx, hy, hz) + + if inFront then + fire(sx, sy, true) + lastShot = t + end + end + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored rail shooter, %.1fs, zombies %d, kills %d, heads %d, stops %d", t, authorCount("zombie"), kills, heads, stopped)) + if (shots == 0 and t > 5) or (shots == 1 and kills > 0) or (shots == 2 and stopped == 2) or (shots == 3 and ended) then + shots = shots + 1 + singeScreenshot() + end + if (ended and t > 1) or (t > GIVE_UP) then + debugPrint(string.format("RAIL RESULT score=%d health=%d bites=%d stops=%d kills=%d hits=%d heads=%d ended=%s after=%.1fs", + AUTHOR_VARS.score, AUTHOR_VARS.health, AUTHOR_VARS.bites, stopped, kills, hits, heads, tostring(ended), t)) + if stopped < 2 then debugPrint("RAIL FAIL the rail did not reach both stops") end + 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 + singeQuit() + end + + return r +end diff --git a/testScripts/scene62.singe b/testScripts/scene62.singe new file mode 100644 index 000000000..0eba7c5df --- /dev/null +++ b/testScripts/scene62.singe @@ -0,0 +1,64 @@ +-- Forge milestone 2, genre two: the 3D platformer. A character on the controller moving +-- relative to a following camera, a jump onto a ledge, and a prize that is a trigger. Driven by +-- what the hero is doing, as scene52 drives the 2D one. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/platformer3d.game", singeGetDataPath() .. "platformer3d.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local jumped = false +local won = 0 +local GIVE_UP = 25 + + +function onOverlayUpdate() + local t = authorTime() + local hero = authorEntity("hero") + local hx, hy, hz = authorPosition(hero) + + frames = frames + 1 + if frames == 2 then + authorKeyDown(0, SCANCODE.RIGHT.value) + end + -- Jump once the ledge is near and there is ground underfoot. + if (not jumped) and (hx > 3.2) and authorOnGround(hero) then + authorKeyDown(0, SCANCODE.SPACE.value) + jumped = true + elseif jumped then + authorKeyUp(0, SCANCODE.SPACE.value) + end + -- Past the prize, turn back so the ledge is not overshot for good. + if hx > 7.5 then + authorKeyUp(0, SCANCODE.RIGHT.value) + authorKeyDown(0, SCANCODE.LEFT.value) + elseif hx < 5 and jumped and AUTHOR_VARS.won == nil then + authorKeyUp(0, SCANCODE.LEFT.value) + authorKeyDown(0, SCANCODE.RIGHT.value) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored 3D platformer, %.1fs, hero %.1f %.1f %.1f, state %s, score %d", t, hx, hy, hz, hero.vars.state, AUTHOR_VARS.score)) + if (shots == 0 and t > 0.5) or (shots == 1 and jumped and not authorOnGround(hero)) or (shots == 2 and won > 0) then + shots = shots + 1 + singeScreenshot() + end + if AUTHOR_VARS.won and won == 0 then + won = t + end + if (won > 0 and t > won + 0.5) or (t > GIVE_UP) then + debugPrint(string.format("PLATFORM3D RESULT score=%d won=%s hero=%.1f,%.1f after=%.1fs", AUTHOR_VARS.score, tostring(AUTHOR_VARS.won), hx, hy, t)) + if not AUTHOR_VARS.won then debugPrint("PLATFORM3D FAIL the prize was never taken") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene63.singe b/testScripts/scene63.singe new file mode 100644 index 000000000..0e1c0d9c2 --- /dev/null +++ b/testScripts/scene63.singe @@ -0,0 +1,139 @@ +-- 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 +-- by where they project, the camera orbits by key, and a rail point is put where the camera +-- stands. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 15) +local out = singeGetDataPath() +local work = out .. "rail.game" + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +local source = assert(io.open("testScripts/author/railshooter.game", "r")) +local copy = assert(io.open(work, "w")) + +copy:write(source:read("a")) +source:close() +copy:close() + +if not forgeBegin(work) then + debugPrint("VIEW FAIL could not open " .. work) + singeQuit() +end + +local frames = 0 +local stage = 0 +local wall = nil + + +local function key(code, character) + forgeKey(character or 0, code.value) +end + + +local function step() + local room = forgeRoom() + + if stage == 1 then + local built = 0 + + for _ in pairs(FORGE.scene and FORGE.scene.nodes or {}) do + built = built + 1 + end + debugPrint("VIEW the preview holds " .. built .. " of " .. #room.entities .. " entities") + if built ~= #room.entities then + debugPrint("VIEW FAIL the preview is incomplete") + end + + elseif stage == 2 then + -- Pick the left wall by where it projects. + for index, entry in ipairs(room.entities) do + if entry.id == "wallL" then + wall = index + end + end + local entry = room.entities[wall] + local sx, sy, _, inFront = sceneProject(entry.x, entry.y, entry.z) + + forgePress(sx, sy) + if FORGE.selected == wall then + debugPrint("VIEW picked " .. entry.id .. " at its projection " .. math.floor(sx) .. "," .. math.floor(sy)) + else + debugPrint("VIEW FAIL the press at the wall selected " .. tostring(FORGE.selected) .. " (in front " .. tostring(inFront) .. ")") + end + -- Drag it a little to the right on screen and let go: it moves across the floor. + forgeDrag(sx + 40, sy) + forgeRelease() + if (entry.y == 1.5) and ((entry.x ~= -6) or (entry.z ~= -12)) then + debugPrint(string.format("VIEW dragged it to %.1f, %.1f, %.1f, staying on its floor", entry.x, entry.y, entry.z)) + else + debugPrint(string.format("VIEW FAIL the drag left it at %.1f, %.1f, %.1f", entry.x, entry.y, entry.z)) + end + + elseif stage == 3 then + local yaw = FORGE.scene.yaw + local far = FORGE.scene.distance + + key(SCANCODE.J) + key(SCANCODE.J) + key(SCANCODE.I) + debugPrint(string.format("VIEW orbit: yaw %d to %d, distance %.1f to %.1f", yaw, FORGE.scene.yaw, far, FORGE.scene.distance)) + + elseif stage == 4 then + -- A rail point where the camera stands, at a typed time. + key(SCANCODE.TAB) + key(SCANCODE.TAB) + key(SCANCODE.TAB) + local track = room.tracks[1] + local count = #track.points + + key(SCANCODE.K) + key(SCANCODE.RETURN) -- at + for c in string.gmatch("20", ".") do + key(SCANCODE.A, string.byte(c)) + end + key(SCANCODE.RETURN) -- commits at, moves on to x + key(SCANCODE.ESCAPE) + track = forgeRoom().tracks[1] + local last = track.points[#track.points] + + if (#track.points == count + 1) and (last.at == 20) and (last.look ~= nil) then + debugPrint(string.format("VIEW a rail point went in at %d: %.1f, %.1f, %.1f looking at %s", last.at, last.x, last.y, last.z, tostring(last.look[1] or last.look.x))) + else + debugPrint("VIEW FAIL the rail has " .. #track.points .. " points; the last is at " .. tostring(last and last.at)) + end + + elseif stage == 5 then + key(SCANCODE.S) + local reloaded = authorLoad(work) + local built = forgeBuild(out .. "rail.singe") + local text = assert(io.open(built, "r")):read("a") + + debugPrint("VIEW saved: the rail has " .. #reloaded.rooms[1].tracks[1].points .. " points on disc; compiled carries at = 20: " .. tostring(text:find("at = 20") ~= nil)) + debugPrint("VIEW RESULT done") + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 14 == 0 and stage < 6 then + stage = stage + 1 + step() + end + forgeDraw(nil, nil) + colorForeground(255, 255, 255, 255) + fontPrint(210, 8, "3D viewport, stage " .. stage) + if frames == 20 or frames == 50 or frames == 80 then + singeScreenshot() + end + if frames > 100 then + singeQuit() + end + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene64.singe b/testScripts/scene64.singe new file mode 100644 index 000000000..9ddc950aa --- /dev/null +++ b/testScripts/scene64.singe @@ -0,0 +1,145 @@ +-- Forge milestone 3, genre one: the adventure in a painted room. Walk areas baked to a +-- navigation mesh, hotspots, verbs and the sentence line, the parser, an inventory, a dialogue +-- whose choice changes the world, a cut-scene into a second room, and a save and load. Driven the +-- way a player drives it: a verb, a click on a hotspot, a typed line, a numbered choice. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 16) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/adventure2d.game", singeGetDataPath() .. "adventure2d.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local stage = 0 +local shots = 0 +local said = {} +local GIVE_UP = 90 + +local realSay = authorSay +function authorSay(text, seconds) + said[#said + 1] = tostring(text) + return realSay(text, seconds) +end + + +local function clickOn(verb, id) + local target = authorEntity(id) + local bx, by, bw, bh = authorBounds(target) + + authorSetVerb(verb) + authorSetPointer(1, bx + bw / 2, by + bh / 2, true) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) +end + + +local function lastSaid() + return said[#said] or "(nothing)" +end + + +-- Each stage waits for the game to settle -- no sequence running, no line up, nobody walking -- +-- or for a dialogue to offer its choices, before the next. +local function idle() + local hero = authorEntity("hero") + + if authorChoices() ~= nil then + return true + end + + return (not authorBusy()) and ((hero == nil) or not hero.walking) +end + + +local steps = { + function() + debugPrint("ADV the hall has a navigation mesh: " .. tostring(authorRoomNav() ~= nil) .. "; verb is " .. tostring(AUTHOR_VARS.verb)) + clickOn("look", "door") + end, + function() + debugPrint("ADV look at the door said: " .. lastSaid()) + clickOn("take", "key") + end, + function() + debugPrint("ADV took the key: has it " .. tostring(authorHas("key")) .. ", key gone " .. tostring(authorEntity("key") == nil) .. ", hero at " .. math.floor(authorX(authorEntity("hero"))) .. "," .. math.floor(authorY(authorEntity("hero")))) + clickOn("open", "door") + end, + function() + debugPrint("ADV the guard objects: " .. lastSaid()) + clickOn("talk", "guard") + end, + function() + -- The dialogue is up: the first choice, then the one the key allows. + debugPrint("ADV the guard says: " .. lastSaid()) + authorKeyDown(0, SCANCODE.MAIN_1.value) + end, + function() + debugPrint("ADV and then: " .. lastSaid()) + authorKeyDown(0, SCANCODE.MAIN_1.value) + end, + function() + debugPrint("ADV allowed now: " .. tostring(AUTHOR_VARS.allowed) .. "; last line: " .. lastSaid()) + authorSaid("look at the door") + end, + function() + debugPrint("ADV typed 'look at the door': " .. lastSaid()) + authorSaid("frobnicate the key") + end, + function() + debugPrint("ADV typed nonsense: " .. lastSaid()) + clickOn("open", "door") + end, + function() + debugPrint("ADV after the door: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", outside " .. tostring(AUTHOR_VARS.outside)) + authorSaveGame(1) + authorRestart() + debugPrint("ADV restarted: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score) + authorLoadGame(1) + debugPrint("ADV loaded: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", has key " .. tostring(authorHas("key"))) + end, + function() + if AUTHOR_ROOM.name == "yard" and AUTHOR_VARS.score == 3 and authorHas("key") then + debugPrint("ADV RESULT done") + else + debugPrint("ADV FAIL room " .. AUTHOR_ROOM.name .. " score " .. AUTHOR_VARS.score) + end + singeQuit() + end +} + +local waited = 0 + +function onOverlayUpdate() + local t = authorTime() + + frames = frames + 1 + -- Move on when the game is idle for a moment, or after a while regardless. + if idle() then + waited = waited + 1 + else + waited = 0 + end + if (stage < #steps) and ((waited > 12) or (frames == 20)) then + stage = stage + 1 + waited = 0 + steps[stage]() + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(300, 450, string.format("adventure, stage %d, %.1fs", stage, t)) + if (shots == 0 and stage == 2) or (shots == 1 and stage == 5) or (shots == 2 and stage == 10) then + shots = shots + 1 + singeScreenshot() + end + if t > GIVE_UP then + debugPrint("ADV FAIL gave up at stage " .. stage) + singeQuit() + end + + return r +end diff --git a/testScripts/scene65.singe b/testScripts/scene65.singe new file mode 100644 index 000000000..e07d999cd --- /dev/null +++ b/testScripts/scene65.singe @@ -0,0 +1,147 @@ +-- Forge milestone 3, genre two: the same adventure in a modelled room from a fixed camera. The Walk areas baked to a +-- baked from the floor and a hotspot is what the ray through the pointer meets. Driven as scene64 +-- is, with the pointer placed by where each hotspot projects. +-- +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 16) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/adventure3d.game", singeGetDataPath() .. "adventure3d.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local stage = 0 +local shots = 0 +local said = {} +local GIVE_UP = 90 + +local realSay = authorSay +function authorSay(text, seconds) + said[#said + 1] = tostring(text) + return realSay(text, seconds) +end + + +local function clickOn(verb, id) + local target = authorEntity(id) + local x, y, z = authorPosition(target) + local w, h = authorSize(target) + local sx, sy = sceneProject(x, y + h / 2, z) + + authorSetVerb(verb) + authorSetPointer(1, sx, sy, true) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) +end + + +local function lastSaid() + return said[#said] or "(nothing)" +end + + +-- Each stage waits for the game to settle -- no sequence running, no line up, nobody walking -- +-- or for a dialogue to offer its choices, before the next. +local function idle() + local hero = authorEntity("hero") + + if authorChoices() ~= nil then + return true + end + + return (not authorBusy()) and ((hero == nil) or not hero.walking) +end + + +local steps = { + function() + debugPrint("ADV3 the hall has a navigation mesh: " .. tostring(authorRoomNav() ~= nil) .. "; verb is " .. tostring(AUTHOR_VARS.verb)) + clickOn("look", "door") + end, + function() + debugPrint("ADV3 look at the door said: " .. lastSaid()) + clickOn("take", "key") + end, + function() + debugPrint("ADV3 took the key: has it " .. tostring(authorHas("key")) .. ", key gone " .. tostring(authorEntity("key") == nil) .. ", hero at " .. string.format("%.1f", authorX(authorEntity("hero"))) .. "," .. string.format("%.1f", authorField(authorEntity("hero"), "z"))) + clickOn("open", "door") + end, + function() + debugPrint("ADV3 the guard objects: " .. lastSaid()) + clickOn("talk", "guard") + end, + function() + -- The dialogue is up: the first choice, then the one the key allows. + debugPrint("ADV3 the guard says: " .. lastSaid()) + authorKeyDown(0, SCANCODE.MAIN_1.value) + end, + function() + debugPrint("ADV3 and then: " .. lastSaid()) + authorKeyDown(0, SCANCODE.MAIN_1.value) + end, + function() + debugPrint("ADV3 allowed now: " .. tostring(AUTHOR_VARS.allowed) .. "; last line: " .. lastSaid()) + authorSaid("look at the door") + end, + function() + debugPrint("ADV3 typed 'look at the door': " .. lastSaid()) + authorSaid("frobnicate the key") + end, + function() + debugPrint("ADV3 typed nonsense: " .. lastSaid()) + clickOn("open", "door") + end, + function() + debugPrint("ADV3 after the door: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", outside " .. tostring(AUTHOR_VARS.outside)) + authorSaveGame(1) + authorRestart() + debugPrint("ADV3 restarted: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score) + authorLoadGame(1) + debugPrint("ADV3 loaded: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", has key " .. tostring(authorHas("key"))) + end, + function() + if AUTHOR_ROOM.name == "yard" and AUTHOR_VARS.score == 3 and authorHas("key") then + debugPrint("ADV3 RESULT done") + else + debugPrint("ADV3 FAIL room " .. AUTHOR_ROOM.name .. " score " .. AUTHOR_VARS.score) + end + singeQuit() + end +} + +local waited = 0 + +function onOverlayUpdate() + local t = authorTime() + + frames = frames + 1 + -- Move on when the game is idle for a moment, or after a while regardless. + if idle() then + waited = waited + 1 + else + waited = 0 + end + if (stage < #steps) and ((waited > 12) or (frames == 20)) then + stage = stage + 1 + waited = 0 + steps[stage]() + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(300, 450, string.format("adventure in 3D, stage %d, %.1fs", stage, t)) + if (shots == 0 and stage == 2) or (shots == 1 and stage == 5) or (shots == 2 and stage == 10) then + shots = shots + 1 + singeScreenshot() + end + if t > GIVE_UP then + debugPrint("ADV3 FAIL gave up at stage " .. stage) + singeQuit() + end + + return r +end diff --git a/testScripts/scene66.singe b/testScripts/scene66.singe new file mode 100644 index 000000000..f3ab1ef40 --- /dev/null +++ b/testScripts/scene66.singe @@ -0,0 +1,55 @@ +-- Forge milestone 4, genre one: racing. The car is driven by held keys, the rival drives the +-- track of points on its own, a checkpoint is a trigger the car enters, and a lap is two of them. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/racing.game", singeGetDataPath() .. "racing.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local topSpeed = 0 +local rivalStart = nil +local GIVE_UP = 40 + + +function onOverlayUpdate() + local t = authorTime() + local car = authorEntity("car") + local rival = authorEntity("rival") + local cx, cy, cz = authorPosition(car) + local rx, ry, rz = authorPosition(rival) + + frames = frames + 1 + rivalStart = rivalStart or rz + if frames == 2 then + authorKeyDown(0, SCANCODE.UP.value) + end + topSpeed = math.max(topSpeed, car.vars.speed or 0) + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored racing, %.1fs, car z %.1f speed %.1f, rival z %.1f, gate %d, laps %d", t, cz, car.vars.speed or 0, rz, AUTHOR_VARS.next, AUTHOR_VARS.laps)) + if (shots == 0 and t > 1) or (shots == 1 and AUTHOR_VARS.next == 2) or (shots == 2 and AUTHOR_VARS.laps > 0) then + shots = shots + 1 + singeScreenshot() + end + if (AUTHOR_VARS.laps > 0 and t > 1) or (t > GIVE_UP) then + debugPrint(string.format("RACE RESULT laps=%d next=%d topSpeed=%.1f carZ=%.1f rivalMoved=%.1f after=%.1fs", AUTHOR_VARS.laps, AUTHOR_VARS.next, topSpeed, cz, rivalStart - rz, t)) + if topSpeed < 3 then debugPrint("RACE FAIL the car never got going") end + if AUTHOR_VARS.laps < 1 then debugPrint("RACE FAIL no lap was completed") end + if rivalStart - rz < 5 then debugPrint("RACE FAIL the rival did not drive") end + local hz = authorField(authorEntity("hover"), "z") + + debugPrint(string.format("RACE the hovercraft under thrust is at z %.1f (from 10); the hinged bar is at y %.1f", hz, authorField(authorEntity("bar"), "y"))) + if hz > 8 then debugPrint("RACE FAIL the thrust did not move the hovercraft") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene67.singe b/testScripts/scene67.singe new file mode 100644 index 000000000..24aba0f59 --- /dev/null +++ b/testScripts/scene67.singe @@ -0,0 +1,64 @@ +-- Forge milestone 4, genre two: tower defence. Creeps walk a lane on the navigation mesh in +-- waves, turrets are placed by clicks on the floor -- the pointer put where a lane point projects +-- -- and fire projectiles that collide with them. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/towerdefence.game", singeGetDataPath() .. "towerdefence.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local placed = 0 +local maxCreeps = 0 +local GIVE_UP = 45 + + +local function place(x, y, z) + local sx, sy, _, inFront = sceneProject(x, y, z) + + if inFront then + authorSetPointer(1, sx, sy, true) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + placed = placed + 1 + end +end + + +function onOverlayUpdate() + local t = authorTime() + + frames = frames + 1 + -- Two turrets beside the lane, once the scene has drawn so projection works. + if frames == 10 then + place(0, 0, -3.5) + elseif frames == 12 then + place(10, 0, 0) + end + maxCreeps = math.max(maxCreeps, authorCount("creep")) + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored tower defence, %.1fs, creeps %d, turrets %d, killed %d, gold %d, lives %d", t, authorCount("creep"), authorCount("turret"), AUTHOR_VARS.killed, AUTHOR_VARS.gold, AUTHOR_VARS.lives)) + if (shots == 0 and t > 3) or (shots == 1 and AUTHOR_VARS.killed > 0) or (shots == 2 and t > 20) then + shots = shots + 1 + singeScreenshot() + end + local hive = authorEntity("hive") + + if (t > 8 and hive and hive.vars.made >= 10 and authorCount("creep") == 0) or (t > GIVE_UP) then + debugPrint(string.format("TD RESULT turrets=%d killed=%d gold=%d lives=%d maxCreeps=%d after=%.1fs", authorCount("turret"), AUTHOR_VARS.killed, AUTHOR_VARS.gold, AUTHOR_VARS.lives, maxCreeps, t)) + if authorCount("turret") < 2 then debugPrint("TD FAIL the clicks did not place two turrets") end + if AUTHOR_VARS.killed == 0 then debugPrint("TD FAIL no creep was killed") end + if maxCreeps < 3 then debugPrint("TD FAIL the waves never had three creeps walking") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene68.singe b/testScripts/scene68.singe new file mode 100644 index 000000000..820b02c9f --- /dev/null +++ b/testScripts/scene68.singe @@ -0,0 +1,75 @@ +-- Forge milestone 5, genre one: branching video. A window on the disc answered in time sends +-- the disc to the success clip; one missed sends it to the death clip and takes a life; lives +-- run out, the score goes to the board, the results card shows, a coin and start continue. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +-- The master service, stubbed: what is sent is what is checked. +local submitted = {} +function masterSubmitScore(gameId, value, board, meta) + submitted[#submitted + 1] = { value = value, board = board } +end +function masterPump() end + +dofile(authorBuild("testScripts/author/fmv.game", singeGetDataPath() .. "fmv.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local moved = false +local continued = false +local jumps = {} +local lastFrame = -1 +local before = { taken = 0, missed = 0 } +local GIVE_UP = 1500 + + +function onOverlayUpdate() + local disc = discGetFrame() + + frames = frames + 1 + -- The disc jumping backward or far forward is a branch being taken. + if (lastFrame >= 0) and ((disc < lastFrame) or (disc > lastFrame + 20)) then + jumps[#jumps + 1] = lastFrame .. ">" .. disc + end + lastFrame = disc + -- Answer the first window, in time; miss the second on purpose. + if (disc >= 130) and (disc < 150) and not moved and (AUTHOR_VARS.taken == 0) then + authorKeyDown(0, SCANCODE.RIGHT.value) + authorKeyUp(0, SCANCODE.RIGHT.value) + moved = true + end + -- Once it is over, a coin and a start continue. What the run counted is kept first, since + -- the restart begins the vars again. + if AUTHOR_VARS.lives <= 0 and not continued and disc >= 385 then + before = { taken = AUTHOR_VARS.taken, missed = AUTHOR_VARS.missed } + authorSwitchDown(SWITCH_COIN1) + authorSwitchUp(SWITCH_COIN1) + authorSwitchDown(SWITCH_START1) + authorSwitchUp(SWITCH_START1) + continued = true + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored branching video, frame %d, taken %d, missed %d, lives %d", disc, AUTHOR_VARS.taken, AUTHOR_VARS.missed, AUTHOR_VARS.lives)) + if (shots == 0 and disc >= 125) or (shots == 1 and AUTHOR_VARS.taken > 0) or (shots == 2 and AUTHOR_VARS.lives <= 0) then + shots = shots + 1 + singeScreenshot() + end + if (continued and frames > 60) or (frames > GIVE_UP) then + debugPrint(string.format("FMV RESULT taken=%d missed=%d lives=%d credits=%d submitted=%d jumps=%s", before.taken, before.missed, AUTHOR_VARS.lives, AUTHOR_VARS.credits, #submitted, table.concat(jumps, " "))) + if before.taken ~= 1 then debugPrint("FMV FAIL the first move was not taken") end + if before.missed ~= 3 then debugPrint("FMV FAIL three misses should have spent the lives") end + if #submitted ~= 1 then debugPrint("FMV FAIL the score was not sent once") end + if not continued or AUTHOR_VARS.lives ~= 3 then debugPrint("FMV FAIL the continue did not restart with three lives") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene69.singe b/testScripts/scene69.singe new file mode 100644 index 000000000..7cb3f94d3 --- /dev/null +++ b/testScripts/scene69.singe @@ -0,0 +1,179 @@ +-- The editor's milestone 6 (FORGE.md): a picker narrowed by typing, a file field offering the +-- game's files, walk areas and hotspot outlines drawn corner by corner, the dialogue outline, and +-- building with the current room first. Driven by keys and canvas presses, as the others are. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 15) +local out = singeGetDataPath() +local work = out .. "adv.game" + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +local source = assert(io.open("testScripts/author/adventure2d.game", "r")) +local copy = assert(io.open(work, "w")) + +copy:write(source:read("a")) +source:close() +copy:close() + +if not forgeBegin(work) then + debugPrint("EDIT FAIL could not open " .. work) + singeQuit() +end + +local frames = 0 +local stage = 0 + + +local function key(code, character) + forgeKey(character or 0, code.value) +end + + +local function typeIn(text) + for c in string.gmatch(text, ".") do + key(SCANCODE.A, string.byte(c)) + end +end + + +local function step() + if stage == 1 then + -- A rule's condition picked by typing part of its name. + key(SCANCODE.TAB) + key(SCANCODE.TAB) + key(SCANCODE.N) + key(SCANCODE.C) + typeIn("keyH") + local shown = forgePickItems() + + key(SCANCODE.RETURN) + local rule = FORGE.game.rules[#FORGE.game.rules] + + if #shown == 1 and rule.when[1][1] == "keyHeld" then + debugPrint("EDIT typing keyH left one condition and ENTER took it: " .. rule.when[1][1]) + else + debugPrint("EDIT FAIL the narrowed picker showed " .. #shown .. " and took " .. tostring(rule.when[1] and rule.when[1][1])) + end + + elseif stage == 2 then + -- A file field offers the files under the game's directory. + 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.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 FAIL the file picker did not set the file") + end + forgeKindSet(FORGE.game.kinds.hero, "look", "box") + + elseif stage == 3 then + -- A walk area drawn corner by corner, then a hotspot's outline. + key(SCANCODE.TAB) -- rules + key(SCANCODE.TAB) -- tracks + key(SCANCODE.TAB) -- dialogues + key(SCANCODE.TAB) -- entities + forgeSelect(nil) + local walks = #(forgeRoom().walk or {}) + + key(SCANCODE.V) + forgePress(300, 320) + forgePress(500, 320) + forgePress(400, 400) + key(SCANCODE.V) + if #forgeRoom().walk == walks + 1 and #forgeRoom().walk[walks + 1] == 6 then + debugPrint("EDIT a walk area of three corners was drawn") + else + debugPrint("EDIT FAIL the room has " .. #(forgeRoom().walk or {}) .. " walk areas") + end + for index, entry in ipairs(forgeRoom().entities) do + if entry.id == "door" then + forgeSelect(index) + end + end + key(SCANCODE.V) + forgePress(570, 180) + forgePress(630, 180) + forgePress(630, 300) + forgePress(570, 300) + key(SCANCODE.RETURN) + local spot = FORGE.game.kinds.door.behaviours[1] + + if spot.polygon and #spot.polygon == 8 then + debugPrint("EDIT the door's outline has four corners") + else + debugPrint("EDIT FAIL the door's outline is " .. tostring(spot.polygon and #spot.polygon)) + end + + 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) -- rules + key(SCANCODE.TAB) -- tracks + key(SCANCODE.TAB) -- dialogues + debugPrint("EDIT the outline starts on dialogue '" .. tostring(FORGE.dialogue) .. "' with " .. #forgeDialogueRows() .. " rows") + key(SCANCODE.A) -- a node in the guard's dialogue + key(SCANCODE.RETURN) -- its name + typeIn("bye") + key(SCANCODE.RETURN) -- who + key(SCANCODE.ESCAPE) + key(SCANCODE.A) -- a choice in it + key(SCANCODE.T) + typeIn("addSc") + key(SCANCODE.RETURN) + local node = FORGE.game.dialogues.guard.nodes.bye + + if node and node.choices[1] and node.choices[1].act[1][1] == "addScore" then + debugPrint("EDIT a node 'bye' with a choice that adds to the score went into the guard's dialogue") + else + debugPrint("EDIT FAIL the node is " .. tostring(node) .. "; rows " .. #forgeDialogueRows()) + end + + elseif stage == 5 then + -- Saved, and built with the second room first. + key(SCANCODE.S) + local built = forgeBuild(out .. "adv.singe", 2) + local text = assert(io.open(built, "r")):read("a") + local yard = text:find('name = "yard"', 1, true) + local hall = text:find('name = "hall"', 1, true) + + debugPrint("EDIT built from the yard: yard before hall " .. tostring(yard ~= nil and hall ~= nil and yard < hall) .. "; the dialogue carries bye: " .. tostring(text:find('"bye"', 1, true) ~= nil)) + if not (yard and hall and yard < hall) then + debugPrint("EDIT FAIL the built game does not start in the yard") + end + debugPrint("EDIT RESULT done") + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 14 == 0 and stage < 6 then + stage = stage + 1 + step() + end + forgeDraw(nil, nil) + colorForeground(255, 255, 255, 255) + fontPrint(210, 8, "editor, stage " .. stage) + if frames == 30 or frames == 58 or frames == 72 then + singeScreenshot() + end + if frames > 100 then + singeQuit() + end + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene70.singe b/testScripts/scene70.singe new file mode 100644 index 000000000..60bc60082 --- /dev/null +++ b/testScripts/scene70.singe @@ -0,0 +1,82 @@ +-- The long tail (FORGE.md milestone 7), in 2D: a sprite sheet's frames stepped by state, a +-- grid of tiles drawn from a sheet, and MIDI notes reaching the rules as an event. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/tail.game", singeGetDataPath() .. "tail.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local seen = {} +local leftSeen = {} +local shots = 0 + + +function onOverlayUpdate() + local runner = authorEntity("runner") + local t = authorTime() + + frames = frames + 1 + if frames == 2 then + authorKeyDown(0, SCANCODE.RIGHT.value) + elseif frames == 45 then + authorKeyUp(0, SCANCODE.RIGHT.value) + authorKeyDown(0, SCANCODE.LEFT.value) + elseif frames == 60 then + authorMidi(0x90, 60, 100) + authorMidi(0x91, 64, 90) + authorMidi(0x80, 60, 0) + end + if frames > 48 then + leftSeen[runner.vars.frame] = true + else + seen[runner.vars.frame] = true + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored long tail, %.1fs, frame %d, %s", t, runner.vars.frame, runner.vars.state)) + if (shots == 0 and frames == 20) or (shots == 1 and frames == 70) then + shots = shots + 1 + singeScreenshot() + end + if frames > 90 then + local distinct = 0 + + for _ in pairs(seen) do + distinct = distinct + 1 + end + local floor = authorEntity("floor") + local w, h = authorSize(floor) + + debugPrint(string.format("TAIL RESULT frames=%d state=%s score=%d notes=%d grid=%dx%d", distinct, runner.vars.state, AUTHOR_VARS.score, AUTHOR_VARS.notes, w, h)) + if distinct < 3 then debugPrint("TAIL FAIL the walk cycle did not step through its frames") end + local leftOnly = true + + for frame in pairs(leftSeen) do + if frame > 2 then + leftOnly = false + end + end + debugPrint("TAIL facing left used only the walkLeft frames: " .. tostring(leftOnly)) + local spinner = authorEntity("spinner") + + 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" } } }) + + 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 + if not leftOnly then debugPrint("TAIL FAIL facing left drew frames outside walkLeft") end + if AUTHOR_VARS.score ~= 10 or AUTHOR_VARS.notes ~= 2 then debugPrint("TAIL FAIL the notes were not counted: score " .. AUTHOR_VARS.score .. " notes " .. AUTHOR_VARS.notes) end + if w ~= 160 or h ~= 96 then debugPrint("TAIL FAIL the grid is not 5 by 3 tiles of 32") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene71.singe b/testScripts/scene71.singe new file mode 100644 index 000000000..2ec93218b --- /dev/null +++ b/testScripts/scene71.singe @@ -0,0 +1,49 @@ +-- The long tail (FORGE.md milestone 7), in 3D: a body floating in water, a boat under its +-- propeller, and a soft body. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/pool.game", singeGetDataPath() .. "pool.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local boatStart = nil +local lowest = math.huge + + +function onOverlayUpdate() + local t = authorTime() + local ball = authorEntity("ball") + local boat = authorEntity("boat") + local by = authorY(ball) + + frames = frames + 1 + boatStart = boatStart or authorField(boat, "z") + if t > 2 then + lowest = math.min(lowest, by) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored pool, %.1fs, ball y %.2f, boat z %.1f", t, by, authorField(boat, "z"))) + if (shots == 0 and t > 1) or (shots == 1 and t > 4) then + shots = shots + 1 + singeScreenshot() + end + if t > 6 then + local moved = boatStart - authorField(boat, "z") + + debugPrint(string.format("POOL RESULT ballLowest=%.2f ballNow=%.2f boatMoved=%.1f balloon=%s", lowest, by, moved, tostring(authorEntity("balloon") ~= nil))) + if lowest < -1.5 then debugPrint("POOL FAIL the ball sank to the bottom") end + if moved < 2 then debugPrint("POOL FAIL the boat did not move") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene72.singe b/testScripts/scene72.singe new file mode 100644 index 000000000..f2b77249e --- /dev/null +++ b/testScripts/scene72.singe @@ -0,0 +1,96 @@ +-- The first-person shooter from FORGE.md's catalogue: a camera on the hero's eyes turned by mouse +-- motion, a gun firing from the middle of the picture, keys moving relative to where the camera +-- looks, and enemies walking the mesh after the hero. The mouse is injected to turn toward the +-- nearest zombie's head before each shot. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/fps.game", singeGetDataPath() .. "fps.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local hits = 0 +local kills = 0 +local lastShot = 0 +local walked = false +local startZ = nil +local GIVE_UP = 45 +local LOOK_SPEED = 0.2 + +local realEvent = authorEvent +function authorEvent(name, event) + if name == "hit" then hits = hits + 1 elseif name == "death" then kills = kills + 1 end + return realEvent(name, event) +end + + +function onOverlayUpdate() + local t = authorTime() + local hero = authorEntity("hero") + local eyes = authorEntity("eyes") + local hx, hy, hz = authorPosition(hero) + + frames = frames + 1 + startZ = startZ or hz + -- A step forward first, to show the keys move relative to the view. + if frames == 5 then + authorKeyDown(0, SCANCODE.W.value) + elseif frames == 40 then + authorKeyUp(0, SCANCODE.W.value) + walked = true + end + -- Turn toward the nearest zombie's head and fire. + if walked and (t - lastShot > 0.3) then + local nearest, best = nil, math.huge + + for _, zombie in ipairs(authorEach("zombie")) do + if zombie.vars.state ~= "dead" then + local far = authorDistance3D(zombie, hero) + + if far < best then + nearest, best = zombie, far + end + end + end + if nearest then + local head = nodeFind("b_Head_05", nearest.model) + local tx, ty, tz = nodeGetWorldPosition(head) + local ex, ey, ez = authorPosition(eyes) + local wantYaw = math.deg(math.atan(-(tx - ex), -(tz - ez))) + local wantPitch = math.deg(math.atan(ty - ey, math.sqrt((tx - ex) ^ 2 + (tz - ez) ^ 2))) + local dYaw = (eyes.yaw - wantYaw + 540) % 360 - 180 + local dPitch = eyes.pitch - wantPitch + + authorMouseMoved(0, 0, dYaw / LOOK_SPEED, dPitch / LOOK_SPEED) + if math.abs(dYaw) < 2 then + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + lastShot = t + end + end + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored first person, %.1fs, yaw %.0f, hits %d, kills %d, zombies %d", t, eyes.yaw, hits, kills, authorCount("zombie"))) + if (shots == 0 and t > 1) or (shots == 1 and kills > 0) or (shots == 2 and kills >= 4) then + shots = shots + 1 + singeScreenshot() + end + local hive = authorEntity("hive") + + if (hive.vars.made >= 6 and authorCount("zombie") == 0 and t > 5) or (t > GIVE_UP) then + debugPrint(string.format("FPS RESULT kills=%d hits=%d health=%d bites=%d movedForward=%.1f after=%.1fs", kills, hits, AUTHOR_VARS.health, AUTHOR_VARS.bites, startZ - hz, t)) + if kills < 4 then debugPrint("FPS FAIL fewer than four zombies were killed") end + if startZ - hz < 1 then debugPrint("FPS FAIL W did not move the hero forward") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene73.singe b/testScripts/scene73.singe new file mode 100644 index 000000000..5340cb217 --- /dev/null +++ b/testScripts/scene73.singe @@ -0,0 +1,53 @@ +-- Arcade physics (FORGE.md's catalogue): a ball on a 2D body bouncing off static bricks and a +-- kinematic paddle the keys move; bricks broken by collision rules. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +dofile(authorBuild("testScripts/author/breakout.game", singeGetDataPath() .. "breakout.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local paddleStart = nil +local GIVE_UP = 30 + + +function onOverlayUpdate() + local t = authorTime() + local ball = authorEntity("ball") + local paddle = authorEntity("paddle") + local bx, by = authorPosition(ball) + local px = authorX(paddle) + + frames = frames + 1 + paddleStart = paddleStart or px + -- The paddle chases the ball, which is what keeps it in play. + if bx < px - 10 then + authorKeyUp(0, SCANCODE.RIGHT.value) + authorKeyDown(0, SCANCODE.LEFT.value) + elseif bx > px + 10 then + authorKeyUp(0, SCANCODE.LEFT.value) + authorKeyDown(0, SCANCODE.RIGHT.value) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored breakout, %.1fs, ball %.0f,%.0f, broken %d, lives %d", t, bx, by, AUTHOR_VARS.broken, AUTHOR_VARS.lives)) + if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.broken > 0) then + shots = shots + 1 + singeScreenshot() + end + if (AUTHOR_VARS.broken >= 2) or (t > GIVE_UP) then + debugPrint(string.format("BREAKOUT RESULT broken=%d lives=%d paddleMoved=%s after=%.1fs", AUTHOR_VARS.broken, AUTHOR_VARS.lives, tostring(math.abs(px - paddleStart) > 5), t)) + if AUTHOR_VARS.broken < 1 then debugPrint("BREAKOUT FAIL no brick was broken") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene74.singe b/testScripts/scene74.singe new file mode 100644 index 000000000..cb10600a7 --- /dev/null +++ b/testScripts/scene74.singe @@ -0,0 +1,51 @@ +-- A maze (FORGE.md's catalogue): a tile map for the walls, corridors that are a walk area, a +-- hero sent by a click, dots eaten on touch, and a ghost that follows the hero across the mesh. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +dofile(authorBuild("testScripts/author/maze.game", singeGetDataPath() .. "maze.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local gapStart = nil +local GIVE_UP = 25 + + +function onOverlayUpdate() + local t = authorTime() + local hero = authorEntity("hero") + local ghost = authorEntity("ghost") + local gap = authorDistance(hero, ghost) + + frames = frames + 1 + gapStart = gapStart or gap + if frames == 10 then + -- A click at the far end of the top corridor. + authorSetPointer(1, 340, 180, true) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored maze, %.1fs, hero %.0f,%.0f, ghost gap %.0f, eaten %d", t, authorX(hero), authorY(hero), gap, AUTHOR_VARS.eaten)) + if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.eaten > 0) then + shots = shots + 1 + singeScreenshot() + end + if (AUTHOR_VARS.eaten >= 2 and gap < gapStart - 50) or (t > GIVE_UP) then + debugPrint(string.format("MAZE RESULT eaten=%d gap=%.0f (from %.0f) caught=%s after=%.1fs", AUTHOR_VARS.eaten, gap, gapStart, tostring(AUTHOR_VARS.caught), t)) + if AUTHOR_VARS.eaten < 2 then debugPrint("MAZE FAIL the hero did not eat two dots on its way") end + if gap >= gapStart - 50 then debugPrint("MAZE FAIL the ghost did not close on the hero") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene75.singe b/testScripts/scene75.singe new file mode 100644 index 000000000..37d1a7fdc --- /dev/null +++ b/testScripts/scene75.singe @@ -0,0 +1,77 @@ +-- Third-person action (FORGE.md's catalogue): a camera orbiting the hero under injected mouse +-- motion, keys moving the hero relative to it, enemies following across the mesh, and a sword +-- swung by a key at what is in reach. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/thirdperson.game", singeGetDataPath() .. "thirdperson.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local kills = 0 +local yawStart = nil +local startX = nil +local lastSwing = 0 +local GIVE_UP = 40 + +local realEvent = authorEvent +function authorEvent(name, event) + if name == "death" then kills = kills + 1 end + return realEvent(name, event) +end + + +function onOverlayUpdate() + local t = authorTime() + local hero = authorEntity("hero") + local camera = authorEntity("camera") + local hx, hy, hz = authorPosition(hero) + + frames = frames + 1 + yawStart = yawStart or camera.yaw + startX = startX or hx + -- Orbit the camera a quarter turn over the first second, then walk forward for two. + if frames >= 5 and frames < 35 then + authorMouseMoved(0, 0, 15, 0) + elseif frames == 35 then + authorKeyDown(0, SCANCODE.W.value) + elseif frames == 155 then + authorKeyUp(0, SCANCODE.W.value) + end + -- Swing at anything close. + if (t - lastSwing > 0.4) then + for _, enemy in ipairs(authorEach("enemy")) do + if (enemy.vars.state ~= "dead") and (authorDistance3D(enemy, hero) < 2.2) then + authorKeyDown(0, SCANCODE.SPACE.value) + authorKeyUp(0, SCANCODE.SPACE.value) + lastSwing = t + break + end + end + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored third person, %.1fs, yaw %.0f, hero %.1f,%.1f, kills %d, swings %d", t, camera.yaw, hx, hz, kills, AUTHOR_VARS.swings)) + if (shots == 0 and frames == 40) or (shots == 1 and kills > 0) then + shots = shots + 1 + singeScreenshot() + end + local hive = authorEntity("hive") + + if (hive.vars.made >= 4 and authorCount("enemy") == 0 and t > 5) or (t > GIVE_UP) then + debugPrint(string.format("TPS RESULT kills=%d swings=%d health=%d yawTurned=%.0f movedX=%.1f after=%.1fs", kills, AUTHOR_VARS.swings, AUTHOR_VARS.health, camera.yaw - yawStart, hx - startX, t)) + 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 + singeQuit() + end + + return r +end diff --git a/testScripts/scene76.singe b/testScripts/scene76.singe new file mode 100644 index 000000000..f446683b8 --- /dev/null +++ b/testScripts/scene76.singe @@ -0,0 +1,49 @@ +-- A space shooter (FORGE.md's catalogue): a ship under thrust with no gravity, bolts fired +-- forward, rocks coming at it broken by collision rules, and a camera behind. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/space.game", singeGetDataPath() .. "space.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local startZ = nil +local maxBolts = 0 +local GIVE_UP = 40 + + +function onOverlayUpdate() + local t = authorTime() + local ship = authorEntity("ship") + local sx, sy, sz = authorPosition(ship) + + frames = frames + 1 + startZ = startZ or sz + if frames == 3 then + authorKeyDown(0, SCANCODE.UP.value) + authorKeyDown(0, SCANCODE.SPACE.value) + end + maxBolts = math.max(maxBolts, authorCount("bolt")) + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored space, %.1fs, ship z %.1f, bolts %d, rocks %d, broken %d", t, sz, authorCount("bolt"), authorCount("rock"), AUTHOR_VARS.rocks)) + if (shots == 0 and t > 2) or (shots == 1 and AUTHOR_VARS.rocks > 0) then + shots = shots + 1 + singeScreenshot() + end + if (AUTHOR_VARS.rocks >= 3) or (t > GIVE_UP) then + debugPrint(string.format("SPACE RESULT broken=%d movedForward=%.1f maxBolts=%d after=%.1fs", AUTHOR_VARS.rocks, startZ - sz, maxBolts, t)) + if AUTHOR_VARS.rocks < 1 then debugPrint("SPACE FAIL no rock was broken") end + if startZ - sz < 3 then debugPrint("SPACE FAIL the thrust did not move the ship") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene77.singe b/testScripts/scene77.singe new file mode 100644 index 000000000..11620d50b --- /dev/null +++ b/testScripts/scene77.singe @@ -0,0 +1,52 @@ +-- A rhythm game (FORGE.md's catalogue): notes that fall to a marker, a press inside the window +-- scoring and one outside missing, over a music layer. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +dofile(authorBuild("testScripts/author/rhythm.game", singeGetDataPath() .. "rhythm.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local pressed = {} +local GIVE_UP = 8 + + +function onOverlayUpdate() + local t = authorTime() + + frames = frames + 1 + -- Press when a note is at the marker, once per note, and once at nothing. + for _, note in ipairs(authorEach("note")) do + if (math.abs(authorY(note) - 400) < 12) and not pressed[note.id] then + pressed[note.id] = true + authorKeyDown(0, SCANCODE.SPACE.value) + authorKeyUp(0, SCANCODE.SPACE.value) + end + end + if frames == 10 then + authorKeyDown(0, SCANCODE.SPACE.value) + authorKeyUp(0, SCANCODE.SPACE.value) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored rhythm, %.1fs, notes %d, hits %d, misses %d, music %s", t, authorCount("note"), AUTHOR_VARS.hits, AUTHOR_VARS.misses, tostring(musicIsPlaying()))) + if (shots == 0 and t > 1.5) or (shots == 1 and AUTHOR_VARS.hits > 0) then + shots = shots + 1 + singeScreenshot() + end + if t > GIVE_UP then + debugPrint(string.format("RHYTHM RESULT hits=%d misses=%d score=%d", AUTHOR_VARS.hits, AUTHOR_VARS.misses, AUTHOR_VARS.score)) + if AUTHOR_VARS.hits < 3 then debugPrint("RHYTHM FAIL fewer than three notes were hit") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene78.singe b/testScripts/scene78.singe new file mode 100644 index 000000000..82f425894 --- /dev/null +++ b/testScripts/scene78.singe @@ -0,0 +1,43 @@ +-- Bowling (FORGE.md's catalogue): a ball shoved down a lane by a press, pins that are bodies +-- counted as they fall, and a trigger at the end of the lane. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/bowling.game", singeGetDataPath() .. "bowling.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local GIVE_UP = 20 + + +function onOverlayUpdate() + local t = authorTime() + local ball = authorEntity("ball") + + frames = frames + 1 + if frames == 5 then + authorKeyDown(0, SCANCODE.SPACE.value) + authorKeyUp(0, SCANCODE.SPACE.value) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored bowling, %.1fs, ball z %.1f, pins down %d", t, authorField(ball, "z"), AUTHOR_VARS.down)) + if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.down > 0) then + shots = shots + 1 + singeScreenshot() + end + if (AUTHOR_VARS.done and t > 3) or (t > GIVE_UP) then + debugPrint(string.format("BOWL RESULT down=%d done=%s ballZ=%.1f after=%.1fs", AUTHOR_VARS.down, tostring(AUTHOR_VARS.done), authorField(ball, "z"), t)) + if AUTHOR_VARS.down < 1 then debugPrint("BOWL FAIL no pin fell") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene79.singe b/testScripts/scene79.singe new file mode 100644 index 000000000..cde0bec12 --- /dev/null +++ b/testScripts/scene79.singe @@ -0,0 +1,51 @@ +-- Occluders (FORGE.md): a painted room in 3D with a pillar that is only in the picture. The +-- pillar is an invisible box that writes depth alone, so the picture shows through it and the hero +-- disappears behind it on the way across. Three shots: before, behind, after. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/painted.game", singeGetDataPath() .. "painted.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 +local GIVE_UP = 12 + + +function onOverlayUpdate() + local t = authorTime() + local hero = authorEntity("hero") + local hx = authorX(hero) + + frames = frames + 1 + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("authored painted room, %.1fs, hero x %.1f, pillar %s", t, hx, hero.material and "" or "is an occluder")) + if (shots == 0 and hx > -3.5) or (shots == 1 and hx > -0.3) or (shots == 2 and hx > 3) then + shots = shots + 1 + singeScreenshot() + end + if AUTHOR_VARS.across or (t > GIVE_UP) then + local pillar = authorEntity("pillar") + + local _, ry, _ = nodeGetRotation(authorEntity("far").node) + + debugPrint(string.format("PAINTED RESULT across=%s heroX=%.1f occluderCall=%s pillarMaterial=%s markerYaw=%.0f", tostring(AUTHOR_VARS.across), hx, tostring(type(materialSetOccluder) == "function"), tostring(pillar.material ~= nil), ry)) + if math.abs(ry - 90) > 1 then debugPrint("PAINTED FAIL the marker's ry did not reach its node") end + local sx = nodeGetScale(authorEntity("far").node) + local w = authorSize(authorEntity("far")) + + debugPrint(string.format("PAINTED the marker's scale of 2 gives node scale %.1f and a counted width of %.1f", sx, w)) + if math.abs(sx - 2) > 0.01 then debugPrint("PAINTED FAIL the scale did not reach the node") end + if not AUTHOR_VARS.across then debugPrint("PAINTED FAIL the hero did not cross") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene80.singe b/testScripts/scene80.singe new file mode 100644 index 000000000..c41d31ded --- /dev/null +++ b/testScripts/scene80.singe @@ -0,0 +1,131 @@ +-- Gizmos in the 3D viewport (FORGE.md): the selected entity carries three arms along the world +-- axes, and dragging an arm's handle moves the entity along that axis alone -- the pointer's +-- travel along the arm, as a fraction of the arm, is the distance moved. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 15) +local out = singeGetDataPath() +local work = out .. "gizmo.game" + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +local source = assert(io.open("testScripts/author/railshooter.game", "r")) +local copy = assert(io.open(work, "w")) + +copy:write(source:read("a")) +source:close() +copy:close() + +if not forgeBegin(work) then + debugPrint("GIZMO FAIL could not open " .. work) + singeQuit() +end + +local frames = 0 +local stage = 0 + + +local function handle(axis) + for _, item in ipairs(forgeGizmoHandles()) do + if item.axis == axis then + return item + end + end + + return nil +end + + +local function step() + local room = forgeRoom() + + if stage == 1 then + for index, entry in ipairs(room.entities) do + if entry.id == "wallL" then + forgeSelect(index) + end + end + local arms = forgeGizmoHandles() + + debugPrint("GIZMO the selected wall shows " .. #arms .. " arms") + if #arms ~= 4 then + debugPrint("GIZMO FAIL expected three axis arms and the turn handle") + end + + elseif stage == 2 then + -- Drag the Y handle the length of its own arm: the wall rises one arm's length (1.5) and + -- nothing else about it changes. + local entry = room.entities[FORGE.selected] + local was = { x = entry.x, y = entry.y, z = entry.z } + local arm = handle("y") + + forgePress(arm.tx, arm.ty) + forgeDrag(arm.tx + (arm.tx - arm.sx), arm.ty + (arm.ty - arm.sy)) + forgeRelease() + debugPrint(string.format("GIZMO the Y arm took the wall from %.1f to %.1f; x %.1f to %.1f, z %.1f to %.1f", was.y, entry.y, was.x, entry.x, was.z, entry.z)) + if math.abs(entry.y - (was.y + 1.5)) > 0.2 or entry.x ~= was.x or entry.z ~= was.z then + debugPrint("GIZMO FAIL the Y drag did not move along Y alone") + end + + elseif stage == 3 then + -- And the X handle, dragged half an arm back. + local entry = room.entities[FORGE.selected] + local was = entry.x + local arm = handle("x") + + forgePress(arm.tx, arm.ty) + forgeDrag(arm.tx - (arm.tx - arm.sx) / 2, arm.ty - (arm.ty - arm.sy) / 2) + forgeRelease() + debugPrint(string.format("GIZMO the X arm took the wall from %.1f to %.1f", was, entry.x)) + if math.abs(entry.x - (was - 0.75)) > 0.2 then + debugPrint("GIZMO FAIL the X drag did not move half an arm back") + end + forgeKey(0, SCANCODE.U.value) + forgeKey(0, SCANCODE.U.value) + -- Undo puts a copy of the description in place, so the room is looked up again. + local back = forgeRoom().entities[FORGE.selected] + + debugPrint(string.format("GIZMO two undos: the wall is back at x %.1f, y %.1f", back.x, back.y)) + if back.x ~= -6 or back.y ~= 1.5 then + debugPrint("GIZMO FAIL undo did not put the wall back") + end + + elseif stage == 4 then + -- The turn handle: dragged a quarter turn round the entity on screen. + local entry = forgeRoom().entities[FORGE.selected] + local arm = handle("ry") + local dx, dy = arm.tx - arm.sx, arm.ty - arm.sy + + forgePress(arm.tx, arm.ty) + forgeDrag(arm.sx - dy, arm.sy + dx) + forgeRelease() + debugPrint(string.format("GIZMO the turn handle set ry to %s", tostring(entry.ry))) + if (entry.ry == nil) or (math.abs(((entry.ry - 270 + 180) % 360) - 180) > 15) then + debugPrint("GIZMO FAIL a quarter turn clockwise on screen should read about 270") + end + debugPrint("GIZMO RESULT done") + end +end + + +function onOverlayUpdate() + frames = frames + 1 + if frames % 14 == 0 and stage < 5 then + stage = stage + 1 + step() + end + forgeDraw(nil, nil) + colorForeground(255, 255, 255, 255) + fontPrint(210, 8, "gizmos, stage " .. stage) + if frames == 20 or frames == 34 then + singeScreenshot() + end + if frames > 80 then + singeQuit() + end + + return OVERLAY_UPDATED +end diff --git a/testScripts/scene81.singe b/testScripts/scene81.singe new file mode 100644 index 000000000..d2c1ba624 --- /dev/null +++ b/testScripts/scene81.singe @@ -0,0 +1,73 @@ +-- Facing and turning sprites in a Forge game: a sheet drawn facing right only is mirrored by +-- the engine while its instance faces left (the runner beside it has frames drawn both ways and +-- is not), a sheet entity with an rz draws its frame turned, and clips play at a volume of their +-- own under the game's. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/tail.game", singeGetDataPath() .. "facing.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local shots = 0 + + +function onOverlayUpdate() + local runner = authorEntity("runner") + local mirror = authorEntity("mirror") + local tilted = authorEntity("tilted") + + frames = frames + 1 + if frames == 2 then + authorKeyDown(0, SCANCODE.RIGHT.value) + elseif frames == 30 then + authorKeyUp(0, SCANCODE.RIGHT.value) + authorKeyDown(0, SCANCODE.LEFT.value) + elseif frames == 40 then + -- The playSound action with a volume, and the sound behaviour's, both under the master. + authorMidi(0x90, 60, 100) + authorBehaviourEvent(mirror, "hit") + local channel = soundPlay(soundLoad("testScripts/crackle.wav"), 0, 20) + + soundSetChannelVolume(channel, 50) + soundSetVolume(40) + debugPrint("FACING a clip on channel " .. channel .. " at its own volume under a master of " .. soundGetVolume()) + end + + local r = gameUpdate() + + colorForeground(255, 255, 255, 255) + fontPrint(12, 450, string.format("facing: runner %s, mirror %s%s, tilted %d degrees", tostring(runner.vars.facing), tostring(mirror.vars.facing), authorMirrored(mirror) and " (mirrored)" or "", tilted.vars.angle or 0)) + if (shots == 0 and frames == 20) or (shots == 1 and frames == 55) then + shots = shots + 1 + singeScreenshot() + end + if frames == 20 then + debugPrint(string.format("FACING going right: runner mirrored %s, mirror mirrored %s", tostring(authorMirrored(runner)), tostring(authorMirrored(mirror)))) + if authorMirrored(runner) or authorMirrored(mirror) then + debugPrint("FACING FAIL nothing should be mirrored while facing right") + end + debugPrint(string.format("FACING the tilted sheet has an image of its own: %s, turned %d", tostring(tilted.ownSprite ~= nil), tilted.ownAngle or -1)) + if (tilted.ownSprite == nil) or (tilted.ownAngle ~= 30) then + debugPrint("FACING FAIL the sheet entity with rz 30 should draw a turned frame") + end + elseif frames == 55 then + debugPrint(string.format("FACING going left: runner mirrored %s (drawn %s), mirror mirrored %s", tostring(authorMirrored(runner)), tostring(runner.drawnFacing), tostring(authorMirrored(mirror)))) + if authorMirrored(runner) or not authorMirrored(mirror) then + debugPrint("FACING FAIL the sheet without left frames should be mirrored, the one with them not") + end + if mirror.ownSprite == nil then + debugPrint("FACING FAIL the mirrored instance should draw from an image of its own") + end + debugPrint("FACING RESULT done") + end + if frames > 70 then + singeQuit() + end + + return r +end diff --git a/testScripts/scene82.singe b/testScripts/scene82.singe new file mode 100644 index 000000000..7f708a754 --- /dev/null +++ b/testScripts/scene82.singe @@ -0,0 +1,111 @@ +-- The 2D canvas's turn handle and the kinds 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. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 15) +local out = singeGetDataPath() +local work = out .. "turn.game" + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) +overlaySetResolution(720, 480) + +local source = assert(io.open("testScripts/author/tail.game", "r")) +local copy = assert(io.open(work, "w")) + +copy:write(source:read("a")) +source:close() +copy:close() + +if not forgeBegin(work) then + debugPrint("TURN FAIL could not open " .. work) + singeQuit() +end + +local frames = 0 +local stage = 0 + + +local function selectEntity(id) + for index, entry in ipairs(forgeRoom().entities) do + if entry.id == id then + forgeSelect(index) + end + end +end + + +local function step() + if stage == 1 then + selectEntity("spinner") + local arm = forgeTurnHandle() + + debugPrint(string.format("TURN the spinner, rz 45, shows its handle at %.0f, %.0f from %.0f, %.0f", arm and arm.tx or -1, arm and arm.ty or -1, arm and arm.sx or -1, arm and arm.sy or -1)) + if (arm == nil) or (arm.tx <= arm.sx) or (arm.ty <= arm.sy) then + debugPrint("TURN FAIL a handle at 45 degrees lies down and right of the entity") + end + + elseif stage == 2 then + -- A quarter turn clockwise round the entity: the handle's offset turned 90 degrees. + local entry = forgeRoom().entities[FORGE.selected] + local arm = forgeTurnHandle() + local dx, dy = arm.tx - arm.sx, arm.ty - arm.sy + + forgePress(arm.tx, arm.ty) + forgeDrag(arm.sx - dy, arm.sy + dx) + forgeRelease() + debugPrint(string.format("TURN a quarter turn clockwise took rz from 45 to %s", tostring(entry.rz))) + if (entry.rz == nil) or (math.abs(entry.rz - 135) > 3) then + debugPrint("TURN FAIL expected about 135") + end + + elseif stage == 3 then + -- Undo puts it back, and a sheet entity with an rz draws its first frame turned. + forgeKey(0, SCANCODE.U.value) + local back = forgeRoom().entities[FORGE.selected] + + debugPrint("TURN undone, rz " .. tostring(back.rz)) + if back.rz ~= 45 then + debugPrint("TURN FAIL undo should give 45 back") + end + selectEntity("tilted") + + elseif stage == 4 then + -- The kinds panel: a thumbnail in front of every name. + while FORGE.mode ~= "kinds" do + forgeKey(0, SCANCODE.TAB.value) + end + forgeRefresh() + local rml = rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById("list").inner_rml + local images = select(2, rml:gsub(" 80 then + singeQuit() + end + + return OVERLAY_UPDATED +end diff --git a/util/forgeVocabulary.lua b/util/forgeVocabulary.lua new file mode 100644 index 000000000..05b9ea49c --- /dev/null +++ b/util/forgeVocabulary.lua @@ -0,0 +1,68 @@ +-- Writes the Forge vocabulary -- every layer, look, behaviour, event, condition, and action the +-- manifest in assets/Forge/Author.singe declares -- as AsciiDoc, for docs/Forge.adoc to include. +-- The manifest is the one source of truth; this keeps the manual from drifting from it. +-- +-- lua5.4 util/forgeVocabulary.lua > docs/ForgeVocabulary.adoc +-- +-- Author.singe touches nothing of the engine when it is loaded, only when a game begins, so plain +-- Lua can read it. + +local function keysOf(t) + local keys = {} + + for key in pairs(t or {}) do + keys[#keys + 1] = key + end + table.sort(keys) + + return keys +end + + +local function paramsText(params) + local parts = {} + + for _, key in ipairs(keysOf(params)) do + parts[#parts + 1] = "`" .. key .. "` (" .. params[key] .. ")" + end + if #parts == 0 then + return "--" + end + + return table.concat(parts, ", ") +end + + +local function section(title, set, extra) + print("=== " .. title) + print("") + print('[cols="1,3,2' .. (extra and ',1' or '') .. '"]') + print("|===") + print("| Name | What it does | Parameters" .. (extra and (" | " .. extra) or "")) + print("") + for _, name in ipairs(keysOf(set)) do + local entry = set[name] + + print("| `" .. name .. "`") + print("| " .. (entry.help or "")) + print("| " .. paramsText(entry.params or entry.filter)) + if extra then + print("| " .. (entry.waits and "yes" or "")) + end + print("") + end + print("|===") + print("") +end + + +dofile("assets/Forge/Author.singe") + +print("// Generated by util/forgeVocabulary.lua from assets/Forge/Author.singe. Do not edit.") +print("") +section("Layers", AUTHOR.layers) +section("Looks", AUTHOR.looks) +section("Behaviours", AUTHOR.behaviours) +section("Events", AUTHOR.events) +section("Conditions", AUTHOR.conditions) +section("Actions", AUTHOR.actions, "Waits")