diff --git a/CHANGELOG b/CHANGELOG index ff71ebe55..b53a526ac 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -26,6 +26,49 @@ API Changes the command line keep their values. Every game in a library therefore behaves the same whether it was started from the menu or from a shell. +- A game can be described rather than written. Singe/Author.singe and + Singe/AuthorCompile.singe take a table of layers, entities, behaviours + and rules and compile it into an ordinary Singe game: the rules become + real Lua, so nothing walks a table every frame and the result can be + read and edited by hand. There is no notion of genre in it -- a game + declares which of the engine's layers it uses, and that is the whole + difference between a platformer and a quick-time event over video. The + vocabulary of conditions and actions is declared in a table rather than + built into the compiler, so new kinds of game are entries rather than + releases, and the "lua" action is the deliberate way out when a rule + needs something the vocabulary cannot say. See the manual. + +- An editor for those descriptions, Forge, which is itself + a Singe game: the canvas is the same overlay at the same coordinates the + game will be played in, so what is placed is what is seen. Entity list + and details are an RmlUi document, the canvas beside them is drawn into + the overlay and picked the way a light gun game picks a target, and the + two compose because a button is offered to the GUI first while pointer + motion is never consumed. A description survives a round trip through + it: load, save, load again, and it compiles to the same game. Forge is + distributed on its own and no part of it ships inside Singe -- not the + editor, not the compiler, not the runtime. A game Forge builds carries + its own copy of that runtime, so it runs on a machine that has never had + Forge on it and cannot change behaviour because the engine moved on. + +- A game released from Forge is standalone. forgeExport writes the + compiled script, a games.dat, the description it came from and a copy of + the runtime into a directory of its own, taken out of Forge. --pack turns + that directory into a .game. The game locates its own directory with + debug.getinfo rather than trusting DIR, which names the directory of the + script the engine was launched with -- not this one when a game is reached + by dofile. + +- The editor edits rules, not only entity positions: Tab swaps the panel + between the entities and the event sheet, the selected rule opens in + place with its conditions and actions under it, and conditions and + actions are added from the same manifest the compiler reads, so the rule + editor needs no change when the vocabulary grows. It is driven by keys + as well as the pointer, which is how the bundled menu has always worked + and what a cabinet needs. ENTER types a value into the selected + condition or action and moves to its next one, ESC puts it back; a + number typed in comes back a number. + - The name now expands to "SINGE Is Not a Game Emulator", in the banner, the manual, INSTALL, and the Windows file description. @@ -995,6 +1038,98 @@ Fixes gun dead with only "No mice detected" to explain it. Only --manymouse needs those devices now, and the message names the group. +- The menu's intro is drawn, not played, and runs straight into the menu. + A charge goes off, the Singe dragon and name fly out of it toward the + camera with the dragon's wings beating, the tagline fades in, the dragon + rears and breathes flame -- and then the grid the menu sits on comes up + out of the dark beneath the logo while the smoke is still clearing, the + logo lifts away, and the menu takes the screen. The dragon settles + centred on the sun rather than above it, with the name below crossing the + horizon, so the sun is a disc behind the animal instead of a rim around + the pair of them. It is one scene from the + first frame to the last: there is no cut and nothing loads at the join, + and the menu takes input when the logo has gone rather than when a video + frame number passes. Returning from a game starts the same timeline past + the end of the intro, so it lands on the settled backdrop. The two + models it is made of are shipped with the engine like the font and the + logos. + +- The menu background video is a recording of that same backdrop, so the + machine with no GPU sees what everybody else draws instead of a + different intro and a different grid. util/renderMenuVideo.py makes it: + a headless Singe draws Singe/Backdrop.singe on a virtual clock that + moves a fixed number of milliseconds a frame, shoots every one of them, + and the frames become assets/menuBackground.mkv. Nothing in the script + knows how long the intro runs or how fast the grid moves -- it asks the + backdrop, which is the only way the recording and the drawing cannot + drift apart -- and it stops after a whole number of turns of the grid, + so the tail the menu plays over and over meets itself exactly. The two + clips the old video was cut from are gone, one of which nobody could + account for. + +- The menu has a sound. util/makeMenuSound.py writes it -- every noise in + it is an oscillator, a burst of noise or an envelope over one of the two, + in a room that is a convolution with a tail of decaying noise, so there is + nothing in it that cannot be accounted for either. The charge, the rush + of the logo arriving, the wingbeats (on the same curve the animation flaps + to), the flame, and a riser as the grid comes up, over a bed whose four + bars land exactly where the menu takes the screen. + The charge and the flame are built the way the things themselves are + heard rather than the way they are drawn. Both were made of smooth + envelopes over steady noise -- the flame of a band of noise wobbled by two + sine waves, which is the textbook fake fire -- and nothing about either is + smooth, steady or periodic. The blast is now a crack, a slam through the + middle of the bass (the band a small speaker turns into "loud", where the + sub it had before moves no air at all), a drop that falls away twice over + and is saturated so its harmonics carry the pitch a cabinet speaker cannot + make, a body that flickers at random as its top closes, and rubble coming + down after it; the fire is a roar whose loudness wanders at random, a + resonance that wanders with it, and the spitting inside it, all surging + together. Everything else ducks under the blast and comes back over a + quarter of a second, because punch is contrast, and the limiter only bends + the very top so the loudest moment keeps the shape it was given. Then it fades out: music under a menu waiting for someone + to choose a game wears out its welcome, so the last seconds of the intro + are the grid running on while the sound leaves, and nothing loops. The + The menu plays the file itself, over whichever renderer is drawing, so + there is one copy of it: muxing it into the recording as well would be + the same nine seconds of sound shipped twice inside one binary. + +- The dragon model is rigged. util/objToGlb.py takes --pivots, a JSON + naming where each object's own origin should sit, and moves the vertices + so the node carries it back as a translation. assets/DragonPivots.json + gives the dragon its joints, so wingL turns at the shoulder and head at + the top of the neck; before, every part's node sat at the model's origin + and turning a wing swung it around the middle of the animal. The model + looks identical -- no part moves by so much as a thousandth -- but it can + now be animated without rigging it again in script each time. + +- A game whose art is missing shows that it is missing, rather than taking + the engine down with it. An empty or wrong decorator path could open + something that is not a file, report a nonsense length, and throw + std::bad_alloc out of the GUI's texture loader -- a typo in a games.dat + ended the program. The loader now refuses an impossible size, and both + menu renderers fall back to Singe/missing.png, drawn at build time, so a + bad path reads as "MISSING art" instead of a hole in the page. + +- GUIs and 2D particles no longer blink. guiDraw and emitterDraw are + documented as lasting one frame, meaning one onOverlayUpdate -- but their + queues were emptied after every rendered frame, and the script's turn is + throttled to FRAME_TICK_MS while the display refreshes as often as it + can. With a 3D scene enabled it refreshes continuously, so the GUIs and + the 2D particles were drawn only on the frames the script happened to run + on: in the menu, a game list flickering over a grid that did not. The + queues are emptied when the script gets its turn instead. + +- The menu shows packed games properly. A .game is a database the engine + presents through the VFS, so lfs.attributes reports it as a directory, + not a file -- and the menu tested the mode before the extension, so every + container went down the loose-directory branch and its games.dat was + loaded with no container recorded. Every CABINET, MARQUEE and ATTRACT + path in it then named a file that exists only inside the database: a + library of .game files showed no art at all and died with "Unable to + open" on the first attract video. The extension is tested first now. + Games packed with earlier releases are correct and do not need repacking. + - A lone .m2v played with --framefile now finds the .ogg beside it, as a framefile's segments always have. A Daphne era elementary stream carries no audio of its own, so lair.m2v played silently unless it was @@ -1085,8 +1220,82 @@ Fixes copy differs from the running build. Upgrading the binary used to keep the old Framework.singe and Menu.singe forever. -- The menu background video is encoded with a keyframe every second, so - the menu's jump to the game list is instant. +- Lua reads a packed game where it lies. Its file handle is a C FILE * and + a database row is not a file, so anything a script opened by name inside + a .game was written out to data//cache first and the copy opened + instead -- a second copy of the game on disk, made a file at a time. + Everything else in the engine had long since stopped needing that: the + sprite loader, the decoders and the GUI's file interface all read a row + as a stream. Only Lua could not, because the io library is not ours. + It is patched now rather than worked around: io.open, io.lines and + io.input hand back a handle of Singe's own for a name that lives only in + the database, and it reads through the same stream everything else uses. + It answers to every method and format Lua's does, io.type calls it a + file, io.read and io.close take it, and it closes itself on collection or + on leaving a variable's scope; writing to it is an error, which + is what a packed asset is. testScripts/packedIo holds all of that to + Lua's own behaviour. With the last reader of the cache gone, the cache + itself is gone: vfsFilePath answers with a path only when there is a + real file, and a packed name being read is asked for as a stream. + +- The menu no longer unpacks a library's artwork to look at it. It asked + whether each picture was there by opening it, and io.open has to hand Lua + a file the C library can read, so every cabinet and marquee in a packed + library was written out to data//cache first -- none of which was + ever opened that way: spriteLoad and the document's texture loader both + read straight out of the database. The question is asked of the index + now, through lfs.attributes, which opens nothing. The cache remains for + what it is for: a game's own script opening its own files by name. + +- A packed game writes to one data directory. Everything that named a file + inside a .game by a path that passes through it -- a script opening one + of its own videos, which is how the menu loads an attract clip -- got a + data directory named after the container including the extension, while + the container itself, and everything else the same game wrote, used the + name without it. A library of packed games therefore grew two data + directories each: the video indexes in one and the saves, the overlay + and the cache in the other. Any component naming a database is reduced + to its stem now, so both land in the same place. An installation that + has both may delete the ".game" one; what was in it is rebuilt. + +- Both menus scroll their details. When a games.dat entry has more to say + than fits, the text walks down by itself a line at a time and rests at + each end: up and down belong to the library, and a cabinet has no key to + spare for scrolling a paragraph. The document renderer's description box + had a scroll bar the style sheet gave it and nothing to drive it with; + the overlay renderer clipped and left it at that. + +- The overlay menu -- the one a machine with no GPU gets -- shows what a + games.dat says about a game. Its details had the attract video's column + and whatever height was left under it, which at 720x480 was four lines, + so the description used most of them and every fact about the game fell + off the bottom. They run the full width now, under the artwork, with the + short fields grouped onto shared lines the way the document renderer + groups them and the description last rather than first. Cabinet and + marquee artwork is scaled to fit its region as well: spriteDraw blits at + the picture's own size, so art larger than the space allowed for it used + to be drawn straight over whatever was beside it. + +- The overlay menu says which key does what, along the bottom, as the + document menu always has. There was no way to learn that up and down + move one game and left and right move a page except to be told. + +- Left and right move a third of the library, or a screenful, whichever is + smaller. A page in a library of nine games was ten, so every press + landed on the first game or the last and nothing between them could be + reached that way at all. + +- The menu's Start button is gone. Nothing but a mouse could ever reach it + -- the document takes no keyboard focus, and a cabinet has no pointer -- + and the line beside it already says which button starts a game. Clicking + a game's row still starts it. + +- The menu background video carries a keyframe every half second, and the + first frame of its looping section is one of them, so the menu's jumps + into and around it are instant. It has no audio track and is compressed + harder than the clips it replaced -- flat colour with hard edges is easy + to encode -- so what was 26 MB of source clips and 1.3 MB inside the + binary is now 0.9 MB of picture and 1.1 MB of sound. - Framefiles whose first line is a relative directory produced garbage video paths (the directory string was freed before use); a lone "." now diff --git a/CMakeLists.txt b/CMakeLists.txt index 949aa6576..1ecb6114a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,6 +173,20 @@ add_custom_command( ) singeEmbed(${GENERATED_DIR}/icon.png ${GENERATED_DIR}/icon.h "") singeEmbedImage(kangarooPunchLogo) + +# The picture shown where a game's art should be. Drawn here rather than kept as a file: it is +# ours, it needs no licence, and it cannot drift from what the code expects. Loud on purpose -- +# a blank space reads as a layout mistake, this reads as "your path is wrong". +add_custom_command( + OUTPUT ${GENERATED_DIR}/missing.png + COMMAND ${IMAGEMAGICK} -size 256x256 pattern:checkerboard -auto-level -fill "#c0208090" -colorize 60 + -fill "#ffd84a" -pointsize 34 -gravity center -annotate +0-14 "MISSING" + -fill "#ffffffcc" -pointsize 18 -annotate +0+22 "art" + -bordercolor "#ffd84a" -border 4 ${GENERATED_DIR}/missing.png + COMMENT "Drawing missing.png" + VERBATIM +) +singeEmbed(${GENERATED_DIR}/missing.png ${GENERATED_DIR}/missing_png.h "") singeEmbedImage(singeLogo) # Windows icon for the resource file. @@ -190,8 +204,13 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "") + +# Forge, the authoring tool, is built from assets/Forge but is NOT embedded: it is distributed on +# its own, not with the engine. Nothing of it -- not the editor, not the compiler, not the runtime +# a game it builds loads -- belongs inside Singe. singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/Backdrop.singe ${GENERATED_DIR}/Backdrop_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "") @@ -203,19 +222,20 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rml ${GENERATED_DIR}/subtitle_rml singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rcss ${GENERATED_DIR}/subtitle_rcss.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.h "") -# Menu background video: two clips cropped to 4:3, scaled to 720x480, keyframed every second so the -# menu's seeks are instant and the engine's keyframe warning stays quiet, and joined. -file(WRITE ${GENERATED_DIR}/menuBackground.txt "file ${GENERATED_DIR}/menuBackground1.mkv\nfile ${GENERATED_DIR}/menuBackground2.mkv\n") -add_custom_command( - OUTPUT ${GENERATED_DIR}/menuBackground.mkv - COMMAND ${FFMPEG_TOOL} -y -loglevel error -i "${CMAKE_SOURCE_DIR}/assets/Singe Engine Intro.mpg" -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -force_key_frames "expr:gte(t,n_forced)" -c:a aac -f matroska ${GENERATED_DIR}/menuBackground1.mkv - COMMAND ${FFMPEG_TOOL} -y -loglevel error -i ${CMAKE_SOURCE_DIR}/assets/180503_01_PurpleGrid.mp4 -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -force_key_frames "expr:gte(t,n_forced)" -c:a aac -f matroska ${GENERATED_DIR}/menuBackground2.mkv - COMMAND ${FFMPEG_TOOL} -y -loglevel error -f concat -safe 0 -i ${GENERATED_DIR}/menuBackground.txt -c copy ${GENERATED_DIR}/menuBackground.mkv - DEPENDS "assets/Singe Engine Intro.mpg" assets/180503_01_PurpleGrid.mp4 - COMMENT "Building menuBackground.mkv" - VERBATIM -) -singeEmbed(${GENERATED_DIR}/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "") +# The two models the menu's intro is made of. Singe's own branding, so they belong with the engine +# the way the font and the logos do -- the menu cannot reach into a game directory for them. +singeEmbed(${CMAKE_SOURCE_DIR}/assets/DragonModel.glb ${GENERATED_DIR}/DragonModel_glb.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/SingeText.glb ${GENERATED_DIR}/SingeText_glb.h "") + +# Menu background video: a recording of the backdrop the menu draws, for the machines that cannot +# draw it. It is not built here -- rendering it needs a GPU device and the engine itself, which is +# the wrong way round for a build -- so it is an asset like the models are, made by +# util/renderMenuVideo.py and re-made whenever assets/Backdrop.singe changes. +singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "") + +# The same sound the video carries, for the renderer that draws the backdrop instead of playing it. +# util/renderMenuVideo.py writes the two together. +singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuIntro.flac ${GENERATED_DIR}/menuIntro_flac.h "") # Calibration click for the menu's audio delay screen: 20 ms of white noise with a fast fade. add_custom_command( diff --git a/assets/180503_01_PurpleGrid.mp4 b/assets/180503_01_PurpleGrid.mp4 deleted file mode 100644 index 7f85af425..000000000 --- a/assets/180503_01_PurpleGrid.mp4 +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d0a1b8d7949ee9563177636262612a53d8f4c3700df0c87abb84dbcc15e2fc89 -size 26521887 diff --git a/assets/Backdrop.singe b/assets/Backdrop.singe new file mode 100644 index 000000000..045fb9eae --- /dev/null +++ b/assets/Backdrop.singe @@ -0,0 +1,623 @@ +--[[ + * + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + * + * +--]] + + + +-- The menu's backdrop: the intro and the grid the menu sits on, as one scene. +-- +-- A charge goes off, the Singe dragon and name fly out of it toward the camera, the tagline fades +-- in underneath, the dragon rears and breathes flame -- and then the grid comes up out of the dark +-- beneath the logo while the smoke is still clearing, the logo lifts away, and what is left is the +-- backdrop the menu sits on. One scene from the first frame to the last: no cut, and nothing +-- loads at the join. +-- +-- It lives here rather than inside Singe/MenuDocument.singe because two things draw it: the menu, +-- and util/renderMenuVideo.py, which records it so a machine with no GPU can play what a machine +-- with one draws. A second copy would be a second thing to keep in step, and the recording would +-- quietly stop matching the menu. +-- +-- Needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the path for a machine +-- without one. + +-- The backdrop: the intro and the grid the menu sits on, as one scene. Every number here came +-- from testScripts/menuIntro.singe, which is where the pacing was worked out; changing one is +-- best done there first, where a contact sheet of the whole eight seconds is one command away. +local BLAST_Z = -30.0 -- Where the charge goes off. +local BLAST_Y = 2.5 -- and how high, which is where the logo comes from. +local REST_Z = -8.0 -- Where the logo ends up. +local CAM_Z = 2.0 +local FLY_START = 0.30 -- Seconds: the logo leaves the blast. +local FLY_END = 1.90 -- and has settled. +local TAG_START = 2.10 -- The tagline begins to appear. +local TAG_END = 3.00 +local SPINS = 2.0 -- Turns the logo makes on the way out. +local IDLE_TURN = 7.0 -- Degrees it drifts either side once it has settled. +local IDLE_RATE = 0.9 -- Radians a second of that drift. +local GRID_UP = 4.30 -- Seconds: the grid starts to come up out of the dark. +local GRID_LIT = 5.60 -- and is fully lit. +local LIFT_FROM = 5.90 -- The logo starts to leave. +local LIFT_TO = 7.30 -- and is gone; the menu has the screen from here. +local LOOP_AT = 9.90 -- Where the recording of all this begins its looping section, and + -- where Singe/menuIntro.flac, which Singe/Menu.singe plays over + -- either renderer, has finished fading out. The menu has had the + -- screen since LIFT_TO; these last seconds are the grid running on + -- under it while the music leaves, which is why this is not simply + -- LIFT_TO. It is a whole number of frames -- and of keyframes -- at + -- the step util/renderMenuVideo.py records at, which checks that + -- rather than taking it on trust. +local LIFT_RISE = 13.0 -- World units it climbs on the way out. + +-- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground. +local CELL = 6.0 +local LINE_W = 0.12 +local CROSS_W = 0.18 +local SPREAD = 16.0 +local HALF_X = 12 +local DEPTH = 14 +-- The grid repeats itself every GRID_PERIOD, because it is slid by one cell and wrapped. The +-- period is the constant and the speed follows from it, rather than the other way round, so that +-- a recording of the loop can be a whole number of frames: at 6 units and 9 a second the period +-- was 2/3 of a second, which is not a whole number of frames at any integer step, and the recorded +-- loop could never quite meet itself. +local GRID_PERIOD = 0.66 +local GRID_SPEED = CELL / GRID_PERIOD +local FOG_R = 8 +local FOG_G = 3 +local FOG_B = 20 +local FOG_NEAR = 12 +local FOG_FAR = 72 +local SUN_R = 10.5 +local SUN_Y = 7.6 +local SUN_Z = -34.0 +local SUN_SLICES = 6 +local SUN_SEGMENTS = 72 +local HORIZ_W = 70.0 +local HORIZ_H = 0.5 +local HORIZ_Z = -38.0 +local HORIZ_D = 2.2 +local CAM_Y = 2.7 -- Eye height, shared by the logo and the grid: the backdrop's + -- perspective is tuned to it, and at the intro's old 1.2 the grid came + -- up flattened with its nearest line a slab across the bottom. +local LOGO_Y = 0.15 -- Where the logo settles. It is the dragon that is centred on the + -- sun, not the pair of them: this puts the dragon's middle on the + -- sun's middle, which leaves the name below it crossing the horizon + -- rather than sitting across the sun's face and hiding it. Measured + -- rather than judged by eye -- a still with the dragon hidden gives + -- the sun's disc, one with it shown gives the dragon's -- so it is + -- worth re-measuring if either model changes. + +-- The dragon carries no animation, but util/dragonModel.py builds it out of named parts -- head, +-- neck, wingL, wingR and the rest -- and every one of them is its own node. They all sit at the +-- model's origin though, so turning a wing node would swing the wing round the dragon's middle +-- rather than its shoulder. The joints are baked into the model now; coordinates below are the +-- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z. +local MOUTH = { 3.55, 5.60, 0.00 } -- Where the flame leaves, in model coordinates. +local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see assets/DragonPivots.json. +local FLAP_MAX = 34.0 -- Degrees a wing rises and falls. +local FLAP_FAST = 13.0 -- Radians a second while it is flying out of the blast. +local FLAP_SLOW = 3.4 -- and once it has settled. +local REAR_BACK = 3.05 -- Seconds: the head starts to rear. +local REAR_TOP = 3.40 -- fully back. +local REAR_STRIKE = 3.58 -- and thrown forward. +local BREATH_START = 3.46 +local BREATH_END = 4.35 +local REAR_DONE = 4.90 -- Back to where it started. +local REAR_ANGLE = 34.0 -- Degrees the head tips back. +local STRIKE_ANGLE = -14.0 -- and forward through the strike. +local DRAGON_YAW = -32.0 -- The dragon alone is turned toward the camera, so the wingbeat is + -- seen three quarters on rather than edge on; the name stays square + -- to the camera because it has to be read. +local TAGLINE = "SINGE Is Not a Game Emulator" + + +local backdropStarted = 0 -- singeGetTicks() when the backdrop's clock began. +local backdropTagX = 0 +local backdropTagY = 0 +local blown = false -- Whether the charge has gone off. +local backdropFont = nil + +-- ===== The backdrop ========================================================================== +-- +-- The intro and the grid are one scene: the blast, the logo out of it, the tagline, and then the +-- grid rising out of the dark beneath the logo while the smoke is still clearing, the logo lifting +-- away, and the backdrop left running under the menu. There is no cut and nothing loads at the +-- join, which is the whole point of doing it this way rather than playing a video and switching. + +-- The scene itself. Opaque black to begin with: the intro happens in the dark, and the disc +-- behind it is stopped rather than shown through, so this is the whole picture from the first +-- frame until the grid comes up and the background goes with it. +sceneEnable(true) +sceneSetBackground(0, 0, 0, 255) +sceneSetAmbient(14, 12, 16) +sceneSetBloom(1.2, 0.22) +sceneSetTonemap(TONEMAP_ACES) + + +-- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from +-- assets/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without +-- anything being rigged here. This used to build them at run time from bounding boxes, which +-- worked but left every future use of the model to re-derive the same thing. +local function joint(root, name, parent) + local part = nodeFind(name, root) + + if (part ~= nil) and (parent ~= nil) then + local px, py, pz = nodeGetPosition(part) + local qx, qy, qz = nodeGetPosition(parent) + + -- Both parts carry their own baked joint, measured from the model's origin. Hanging one + -- off the other makes the child's offset count from the parent's, so the two would add up + -- and the head would fly off above the neck -- which is exactly what it did. + nodeSetParent(part, parent) + nodeSetPosition(part, px - qx, py - qy, pz - qz) + end + + return part +end + + +-- Eases in: slow to start, quick away. The logo leaves this way so the move begins as a drift. +local function easeIn(t) + return t * t * t +end + + +-- Eases out: fast off the mark, slow into the stop. +local function easeOut(t) + return 1 - (1 - t) * (1 - t) * (1 - t) +end + + +-- How far through a span the time is, 0 before it and 1 after. +local function span(t, from, to) + return math.max(math.min((t - from) / (to - from), 1), 0) +end + + +local blast = nodeNew() +nodeSetPosition(blast, 0, BLAST_Y, BLAST_Z) + +local smoke = emitterNew(blast) +emitterSetMax(smoke, 400) +emitterSetBlend(smoke, PARTICLE_ALPHA) +emitterSetRadius(smoke, 1.6) +emitterSetSpread(smoke, 360) +emitterSetSpeed(smoke, 2.5, 9.0) +emitterSetDrag(smoke, 1.3) +emitterSetGravity(smoke, 0, 3.2, 0) +emitterSetLife(smoke, 1.2, 2.4) +emitterSetSize(smoke, 3.2, 12.0) +emitterSetSpin(smoke, -40, 40) +emitterSetColor(smoke, 64, 46, 38, 170, 10, 9, 12, 0) + +local core = emitterNew(blast) +emitterSetMax(core, 500) +emitterSetBlend(core, PARTICLE_ADD) +emitterSetRadius(core, 1.1) +emitterSetSpread(core, 360) +emitterSetSpeed(core, 5.0, 20.0) +emitterSetDrag(core, 1.7) +emitterSetGravity(core, 0, 5.0, 0) +emitterSetLife(core, 0.6, 1.3) +emitterSetSize(core, 2.0, 8.2) +emitterSetColor(core, 255, 232, 150, 255, 190, 35, 0, 0) + +local flash = emitterNew(blast) +emitterSetMax(flash, 20) +emitterSetBlend(flash, PARTICLE_ADD) +emitterSetRadius(flash, 0.4) +emitterSetSpread(flash, 360) +emitterSetSpeed(flash, 0, 1.5) +emitterSetLife(flash, 0.10, 0.22) +emitterSetSize(flash, 6.0, 15.0) +emitterSetColor(flash, 255, 255, 235, 255, 255, 150, 40, 0) + +local sparks = emitterNew(blast) +emitterSetMax(sparks, 300) +emitterSetBlend(sparks, PARTICLE_ADD) +emitterSetRadius(sparks, 0.5) +emitterSetSpread(sparks, 360) +emitterSetSpeed(sparks, 12.0, 34.0) +emitterSetDrag(sparks, 0.25) +emitterSetGravity(sparks, 0, -15.0, 0) +emitterSetLife(sparks, 0.5, 1.5) +emitterSetSize(sparks, 0.30, 0.05) +emitterSetSpin(sparks, -200, 200) +emitterSetTrail(sparks, 8, 0.10) +emitterSetColor(sparks, 255, 226, 160, 255, 255, 80, 15, 0) + +-- The blast's own light, so the logo is lit by the fire it comes out of. +local fireLight = lightNew(LIGHT_POINT) +nodeSetParent(fireLight, blast) +lightSetColor(fireLight, 255, 150, 60) +lightSetRange(fireLight, 60) +lightSetIntensity(fireLight, 0) + +-- A key light for the logo once the fire has died, from over the camera's shoulder. +local key = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(key, 4, 6, 10) +nodeLookAt(key, 0, 0, REST_Z) +lightSetIntensity(key, 1.15) + +-- A magenta rim from behind and to the left, the colour the grid behind the menu is drawn in, so +-- the logo is not a flat white cut-out and the intro and the menu share a palette. +local rim = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(rim, -7, 3, REST_Z - 9) +nodeLookAt(rim, 0, 1.5, REST_Z) +lightSetColor(rim, 255, 70, 200) +lightSetIntensity(rim, 2.6) + +-- The dragon over the name, assembled the way testScripts/scene30.singe does it. +local logo = nodeNew() +local text = modelInstance(modelLoad("Singe/SingeText.glb")) +nodeSetParent(text, logo) +local dragon = modelInstance(modelLoad("Singe/DragonModel.glb")) +nodeSetParent(dragon, logo) +nodeSetPosition(dragon, 0, 4.85, 0) +nodeSetRotation(dragon, 0, DRAGON_YAW, 0) +nodeSetScale(dragon, 0.9) +nodeSetScale(logo, 0.5) +nodeSetVisible(logo, false) + +-- The rig. The head hangs off the neck so that rearing the neck carries the head with it. +local wingLeft = joint(dragon, "wingL") +local wingRite = joint(dragon, "wingR") +local neck = joint(dragon, "neck") +-- The head hangs off the neck so that rearing the neck carries the head with it. +local head = joint(dragon, "head", neck) + +-- The flame, on a node at the dragon's mouth so it follows the head round. +local jet = nodeNew() + +nodeSetParent(jet, head) +-- Relative to the head's own joint, which is where its node now sits. +nodeSetPosition(jet, MOUTH[1] - HEAD_AT[1], MOUTH[2] - HEAD_AT[2], MOUTH[3] - HEAD_AT[3]) + +-- The flame's own light, so the dragon is lit by what it is breathing. +local breathLight = lightNew(LIGHT_POINT) + +nodeSetParent(breathLight, jet) +lightSetColor(breathLight, 255, 160, 60) +lightSetRange(breathLight, 14) +lightSetIntensity(breathLight, 0) + +local breath = emitterNew(jet) +emitterSetMax(breath, 400) +emitterSetBlend(breath, PARTICLE_ADD) +emitterSetRadius(breath, 0.05) +emitterSetSpread(breath, 13) +emitterSetSpeed(breath, 4.5, 9.5) +emitterSetDrag(breath, 3.0) +emitterSetLife(breath, 0.28, 0.55) +emitterSetSize(breath, 0.10, 1.30) +emitterSetRate(breath, 260) +emitterSetColor(breath, 255, 244, 200, 255, 235, 60, 0, 0) + +-- The tagline is measured in backdropBegin instead, once a font has been selected: at this point +-- the document renderer has not chosen one, and fontToSprite ends the game without it. + +-- ===== The backdrop ========================================================================== +-- +-- Built now and left unlit. Bringing it up is a ramp on the emissive colours rather than anything +-- appearing, so the grid rises out of the dark the way a light comes on instead of cutting in. + +local function quad(p, i, x0, z0, x1, z1, x2, z2, x3, z3) + local base = #p / 3 + + for _, c in ipairs({ { x0, z0 }, { x1, z1 }, { x2, z2 }, { x3, z3 } }) do + p[#p + 1] = c[1] + p[#p + 1] = 0 + p[#p + 1] = c[2] + end + for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do + i[#i + 1] = base + n + end +end + + +-- A line of constant width thins to nothing as it recedes and breaks into crawling dots; widening +-- it with distance holds it at about the same width on screen the whole way to the horizon. +local function spread(z) + return 1 + (-z) / SPREAD +end + + +-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row. +local function horizonY(z) + return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D)) +end + + +local function gridMesh() + local p = {} + local i = {} + local far = -DEPTH * CELL + local wide = HALF_X * CELL + local hN = LINE_W / 2 + local hF = hN * spread(far) + + for n = -HALF_X, HALF_X do + local x = n * CELL + + quad(p, i, x - hN, CELL, x + hN, CELL, x + hF, far, x - hF, far) + end + for n = -1, DEPTH do + local z = -n * CELL + local h = CROSS_W / 2 * spread(z) + + quad(p, i, -wide, z + h, wide, z + h, wide, z - h, -wide, z - h) + end + + return meshNew(p, nil, nil, i) +end + + +-- The sun: the part of a disc above yClip, as a fan from the middle of the chord. +local function sunMesh(yClip) + local p = { 0, yClip, 0 } + local i = {} + local a0 = math.asin(math.max(math.min(yClip / SUN_R, 1), -1)) + + for n = 0, SUN_SEGMENTS do + local a = a0 + (n / SUN_SEGMENTS) * (math.pi - a0 * 2) + + p[#p + 1] = math.cos(a) * SUN_R + p[#p + 1] = math.sin(a) * SUN_R + p[#p + 1] = 0 + end + for n = 1, SUN_SEGMENTS do + i[#i + 1] = 1 + i[#i + 1] = n + 1 + i[#i + 1] = n + 2 + end + + return meshNew(p, nil, nil, i) +end + + +-- Unlit, and brought up by their base colour rather than by emissive. The intro has a key light +-- and a rim for the logo, and a lit material with a black base still catches their specular: the +-- grid and the sun showed as a grey sheen from the first frame, lit before they were meant to +-- exist. Unlit takes the lights out of it entirely and the ramp below is the only thing that +-- decides when the backdrop appears. +local gridMat = materialNew() +materialSetColor(gridMat, 0, 0, 0) +materialSetUnlit(gridMat, true) +materialSetDoubleSided(gridMat, true) + +local sunMat2 = materialNew() +materialSetColor(sunMat2, 0, 0, 0) +materialSetUnlit(sunMat2, true) +materialSetDoubleSided(sunMat2, true) + +local horizMat = materialNew() +materialSetColor(horizMat, 0, 0, 0) +materialSetUnlit(horizMat, true) +materialSetDoubleSided(horizMat, true) + +local sliceMat = materialNew() +materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B) +materialSetUnlit(sliceMat, true) +materialSetDoubleSided(sliceMat, true) + +local backdrop = nodeNew() +local grid = nodeNew() + +nodeSetMesh(grid, gridMesh(), gridMat) +nodeSetParent(grid, backdrop) + +local horizon = nodeNew() +nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat) +nodeSetRotation(horizon, 90, 0, 0) +nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z) +nodeSetParent(horizon, backdrop) + +local sunCut = horizonY(SUN_Z) - SUN_Y +local backSun = nodeNew() + +nodeSetMesh(backSun, sunMesh(sunCut), sunMat2) +nodeSetPosition(backSun, 0, SUN_Y, SUN_Z) +nodeSetParent(backSun, backdrop) + +for n = 1, SUN_SLICES do + local slice = nodeNew() + local height = 0.62 - (n - 1) * 0.07 + local y = sunCut + 0.5 + (n - 1) * 1.3 + local w = math.sqrt(math.max(SUN_R * SUN_R - (math.abs(y) + height / 2) ^ 2, 0)) + + nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat) + nodeSetRotation(slice, 90, 0, 0) + nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4) + nodeSetParent(slice, backdrop) +end + + +local camera = nodeNew() +nodeSetPosition(camera, 0, CAM_Y, CAM_Z) +cameraSet(camera) + + + + +-- The backdrop is the disc for the intro and the 3D scene after it. The grid section of +-- Singe/menuBackground.mkv is never reached: this renderer has the GPU device the document needs, +-- which is the same one the scene draws with, so the grid is drawn rather than played. +-- One frame of the backdrop's timeline. +function backdropFrame() + local t = (singeGetTicks() - backdropStarted) / 1000.0 + local fly = span(t, FLY_START, FLY_END) + local tag = span(t, TAG_START, TAG_END) + + if not blown then + blown = true + emitterBurst(smoke, 85) + emitterBurst(core, 110) + emitterBurst(flash, 5) + emitterBurst(sparks, 130) + lightSetIntensity(fireLight, 400) + end + + -- The fire's light dies over the first second and a half. + lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5))) + + -- The grid comes up out of the dark while the smoke is still clearing, so the two overlap and + -- there is no moment where one thing has ended and the next has not begun. + local lit = span(t, GRID_UP, GRID_LIT) + + if (lit > 0) and (lit < 1.0001) then + materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit) + materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit) + materialSetColor(sunMat2, 255 * lit, 130 * lit, 55 * lit) + -- The sky stops being black at the same time, or the grid would rise into a void. + sceneSetBackground(FOG_R * lit, FOG_G * lit, FOG_B * lit, 255) + sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR) + end + if lit > 0 then + -- Scrolling from the moment it is visible, so it is already alive when it arrives. + nodeSetPosition(grid, 0, 0, ((t - GRID_UP) * GRID_SPEED) % CELL) + end + + if fly > 0 then + local e = easeOut(fly) + -- Once it has arrived it keeps turning slowly, so the held frame is not a still picture. + local idle = IDLE_TURN * math.sin((t - FLY_END) * IDLE_RATE) * fly + local yaw = 360 * SPINS * (1 - e) + idle + + -- Out of the blast, then up and away once the grid has taken over. Rising rather than + -- fading: a model has no alpha to fade without touching every material it came with. + local leave = easeIn(span(t, LIFT_FROM, LIFT_TO)) + + nodeSetVisible(logo, leave < 1) + nodeSetPosition(logo, 0, LOGO_Y * e + LIFT_RISE * leave, BLAST_Z + (REST_Z - BLAST_Z) * e) + nodeSetRotation(logo, 0, yaw, 0) + + -- Wings: beating hard on the way out, easing to a hover once it has arrived. + local rate = FLAP_FAST + (FLAP_SLOW - FLAP_FAST) * e + local flap = FLAP_MAX * math.sin(t * rate) + + nodeSetRotation(wingLeft, -flap, 0, 0) + nodeSetRotation(wingRite, flap, 0, 0) + + -- The head rears back, snaps forward, and settles; the flame goes with the strike. + local pitch = 0 + + if t < REAR_TOP then + pitch = REAR_ANGLE * span(t, REAR_BACK, REAR_TOP) + elseif t < REAR_STRIKE then + local k = span(t, REAR_TOP, REAR_STRIKE) + + pitch = REAR_ANGLE + (STRIKE_ANGLE - REAR_ANGLE) * k + else + pitch = STRIKE_ANGLE * (1 - easeOut(span(t, REAR_STRIKE, REAR_DONE))) + end + nodeSetRotation(head, 0, 0, pitch) + nodeSetRotation(neck, 0, 0, pitch * 0.35) + + -- The flame leaves the mouth along the dragon's own forward, which the whole logo has been + -- turning; a 3D emitter takes its direction in world axes, so it is turned here by hand. + local aim = math.rad(pitch) + local spin = math.rad(yaw + DRAGON_YAW) + + emitterSetDirection(breath, math.cos(aim) * math.cos(spin), math.sin(aim), -math.cos(aim) * math.sin(spin)) + if t >= BREATH_START and t < BREATH_END then + emitterStart(breath) + lightSetIntensity(breathLight, 26) + else + emitterStop(breath) + lightSetIntensity(breathLight, 0) + end + end + + -- The tagline goes with the logo. It is drawn into the overlay, which the menu clears every + -- frame, so it has to be redrawn here rather than left standing. + local gone = span(t, LIFT_FROM, LIFT_TO - 0.4) + + if (tag > 0) and (gone < 1) then + colorForeground(235, 225, 240, math.floor(255 * tag * (1 - gone))) + fontPrint(backdropTagX, backdropTagY, TAGLINE) + end +end + + + +-- Starts the backdrop's clock. showIntro false starts it past the end of the intro, so returning +-- from a game lands on the settled grid and the same timeline runs on from there. +function backdropBegin(showIntro) + -- A font of its own. The document renderer draws everything else through RmlUi and never + -- selects one, so the tagline -- the only thing put straight into the overlay -- brings it. + backdropFont = fontLoad("Singe/FreeSansBold.ttf", 30) + fontSelect(backdropFont) + fontQuality(FONT_QUALITY_BLENDED) + + -- fontPrint has no centring, so the tagline is measured once through a throwaway sprite; it + -- is drawn rather than kept as a sprite because a sprite cannot be tinted and the fade needs + -- the foreground colour's alpha. + local measure = fontToSprite(TAGLINE) + + backdropTagX = math.floor((overlayGetWidth() - spriteGetWidth(measure)) / 2) + backdropTagY = math.floor(overlayGetHeight() * 0.80) + spriteUnload(measure) + + backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000)) + blown = not showIntro + if not showIntro then + nodeSetVisible(logo, false) + end +end + + +-- Seconds since the backdrop began. +function backdropTime() + return (singeGetTicks() - backdropStarted) / 1000.0 +end + + +-- Whether the intro is over and the screen belongs to whatever sits on the backdrop. +function backdropReady() + return backdropTime() >= LIFT_TO +end + + +-- How long the whole thing lasts, and how long one turn of the grid takes, so a recording knows +-- where the intro ends and how much of the loop to keep. +function backdropIntroSeconds() + return LIFT_TO +end + + +function backdropLoopSeconds() + return GRID_PERIOD +end + + +-- Where the intro ends and the loop begins, which is where the sting hands over to the bed. +function backdropLoopAt() + return LOOP_AT +end + + +function backdropFinish() + if backdropFont then + fontUnload(backdropFont) + backdropFont = nil + end +end diff --git a/assets/DragonModel.glb b/assets/DragonModel.glb index e51ddc35b..fdc59fa3b 100644 --- a/assets/DragonModel.glb +++ b/assets/DragonModel.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:459dfa78f2dacba3bea8d82352d97b3648847bccf936a9e85732e818a3759971 -size 131440 +oid sha256:4133c9338754ec3e4e04a45d335b018f07b8f61a94c840622051108332561538 +size 131996 diff --git a/assets/DragonPivots.json b/assets/DragonPivots.json new file mode 100644 index 000000000..9bf13ac75 --- /dev/null +++ b/assets/DragonPivots.json @@ -0,0 +1,27 @@ +{ + "_comment": [ + "Where each of the dragon's parts turns, for util/objToGlb.py --pivots.", + "Each entry is min, mid or max of that part's own bounding box, per axis, or a number.", + "The dragon faces +X, +Y is up, and the wings reach out along -Z and +Z.", + "Derived from the anatomy in util/dragonModel.py: a joint sits where the part meets the", + "one it hangs from, so a wing turns at the shoulder and the head at the top of the neck.", + "Without these every part's node sits at the model's origin and rotating one swings it", + "around the middle of the animal." + ], + + "head": ["min", "mid", "mid"], + "neck": ["mid", "min", "mid"], + "tail": ["max", "mid", "mid"], + + "wingL": ["max", "mid", "max"], + "wingR": ["max", "mid", "min"], + + "foreLegL": ["mid", "max", "mid"], + "foreLegR": ["mid", "max", "mid"], + "hindLegL": ["mid", "max", "mid"], + "hindLegR": ["mid", "max", "mid"], + + "hornL": ["mid", "min", "mid"], + "hornR": ["mid", "min", "mid"], + "crest": ["mid", "min", "mid"] +} diff --git a/assets/Forge/Author.singe b/assets/Forge/Author.singe new file mode 100644 index 000000000..24555cafc --- /dev/null +++ b/assets/Forge/Author.singe @@ -0,0 +1,546 @@ +--[[ + * + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + * + * +--]] + + + +-- The runtime an authored game is compiled against (PLAN section 58). +-- +-- A game made with the authoring tools is not interpreted: Singe/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. +-- +-- Three nouns, and no notion of genre anywhere: +-- +-- 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. +-- +-- 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 = { + layers = {}, + looks = {}, + behaviours = {}, + conditions = {}, + actions = {} +} + +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. + +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. + +AUTHOR_FONT_POINTS = 18 -- The default the text look draws at. + + +-- 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() + return (singeGetTicks() - AUTHOR_STARTED) / 1000.0 +end + + +function authorEntity(id) + return AUTHOR_WORLD[id] +end + + +-- ===== Layers ================================================================================= +-- +-- 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.", + params = { gravity = "number" }, + begin = function(layer) + physicsSetEnabled(true) + physicsSet2D(true) + physicsSetGravity(0, layer.gravity or GRAVITY_DEFAULT, 0) + end +} + +AUTHOR.layers.overlay = { + help = "Flat drawing over everything: scores, prompts, a HUD.", + params = {}, + begin = function() end +} + +AUTHOR.layers.disc = { + help = "The video the game is played over.", + params = { start = "number" }, + begin = function(layer) + discPlay() + if layer.start then + discSkipToFrame(layer.start) + end + end +} + + +-- ===== Looks ================================================================================== +-- +-- How an entity is drawn. A look draws itself; it never knows which layer it is on. + +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 + + 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) + end + end +} + +AUTHOR.looks.sprite = { + help = "An image, centred on the entity.", + params = { file = "file" }, + load = function(entity) + entity.sprite = spriteLoad(entity.look.file) + end, + draw = function(entity) + local x, y = authorPosition(entity) + + spriteDraw(entity.sprite, x - spriteGetWidth(entity.sprite) / 2, y - spriteGetHeight(entity.sprite) / 2) + end +} + +AUTHOR.looks.text = { + help = "A line of text, its top left at the entity.", + params = { text = "string", r = "number", g = "number", b = "number" }, + draw = function(entity) + local x, y = authorPosition(entity) + + 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) + end + end +} + + +-- ===== 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. + +AUTHOR.behaviours.solid = { + help = "Immovable ground or a wall.", + params = {}, + attach = function(entity) + bodyNew(entity.node, BODY_STATIC, SHAPE_BOX, entity.look.w / 2, entity.look.h / 2, entity.look.w / 2) + end +} + +AUTHOR.behaviours.platformer = { + help = "Runs, falls and jumps: the engine's character controller in 2D.", + params = { speed = "number", jump = "number" }, + attach = function(entity, params) + entity.speed = params.speed or 200 + entity.jump = params.jump or 500 + playerNew(entity.node, SHAPE_CAPSULE, entity.look.w * PLAYER_RADIUS, entity.look.h) + end, + step = function(entity) + -- 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 + end +} + +AUTHOR.behaviours.drift = { + 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 + end, + step = function(entity, dt) + local x, y, z = nodeGetPosition(entity.node) + + nodeSetPosition(entity.node, x + entity.vx * dt, y + entity.vy * dt, z) + end +} + + +-- ===== 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. + +AUTHOR.conditions.keyHeld = { + help = "A key is down.", + params = { key = "scancode" }, + emit = function(p) return string.format("authorKeyHeld(SCANCODE.%s)", p.key) end +} + +AUTHOR.conditions.switchHeld = { + help = "A pad or gun switch is down.", + params = { switch = "switch" }, + emit = function(p) return string.format("authorSwitchHeld(%s)", p.switch) 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 +} + +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 +} + +AUTHOR.conditions.onGround = { + help = "An entity has ground under it.", + params = { entity = "entity" }, + emit = function(p) return string.format("authorOnGround(%q)", p.entity) end +} + +AUTHOR.conditions.touching = { + help = "Two entities overlap.", + params = { entity = "entity", other = "entity" }, + emit = function(p) return string.format("authorTouching(%q, %q)", p.entity, p.other) end +} + +AUTHOR.conditions.below = { + help = "An entity 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 +} + +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.once = { + help = "Only the first time this rule would run.", + params = { tag = "string" }, + emit = function(p) return string.format("authorOnce(%q)", p.tag) end +} + + +-- ===== Actions ================================================================================ +-- +-- Every entry emits a Lua statement. + +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 +} + +AUTHOR.actions.jump = { + help = "Ask a platformer to jump.", + params = { entity = "entity" }, + emit = function(p) return string.format("authorJump(%q)", 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 +} + +AUTHOR.actions.setText = { + help = "Change what a text entity says.", + params = { entity = "entity", text = "expression" }, + emit = function(p) return string.format("authorSetText(%q, %s)", p.entity, p.text) end +} + +AUTHOR.actions.show = { + help = "Show or hide an entity.", + params = { entity = "entity", visible = "boolean" }, + emit = function(p) return string.format("authorShow(%q, %s)", p.entity, tostring(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 +} + +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.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 +} + +AUTHOR.actions.lua = { + help = "Anything the vocabulary cannot say. The way out, and it is meant to be used.", + params = { code = "lua" }, + emit = function(p) return p.code end +} + + +-- ===== The runtime the generated Lua calls ==================================================== + +AUTHOR_SCORE = 0 +AUTHOR_FLAG = {} + +local onceSeen = {} +local lastTime = 0 + + +-- 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 +end + + +function authorKeyHeld(scancode) + return AUTHOR_KEYS[scancode] == true +end + + +function authorSwitchHeld(switch) + return AUTHOR_SWITCHES[switch] == true +end + + +function authorDiscBetween(from, to) + local frame = discGetFrame() + + return (frame >= from) and (frame < to) +end + + +function authorOnGround(id) + return playerIsOnGround(AUTHOR_WORLD[id].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) + + return collideRects(ax - a.look.w / 2, ay - a.look.h / 2, a.look.w, a.look.h, + bx - b.look.w / 2, by - b.look.h / 2, b.look.w, b.look.h) +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 + return false + end + onceSeen[tag] = true + + return true +end + + +function authorRun(id, direction) + AUTHOR_WORLD[id].drive = direction +end + + +function authorJump(id) + local entity = AUTHOR_WORLD[id] + + playerJump(entity.node, entity.jump) +end + + +function authorMoveTo(id, x, y) + local entity = AUTHOR_WORLD[id] + + if entity.player then + playerSetPosition(entity.node, x, y, 0) + else + nodeSetPosition(entity.node, x, y, 0) + end +end + + +function authorSetText(id, text) + AUTHOR_WORLD[id].text = tostring(text) +end + + +function authorShow(id, visible) + AUTHOR_WORLD[id].visible = visible +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] + local b + + nodeSetPosition(entity.node, description.x or 0, description.y or 0, 0) + if look.load then + look.load(entity) + end + AUTHOR_WORLD[entity.id] = entity + AUTHOR_ORDER[#AUTHOR_ORDER + 1] = entity + + 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) .. "'") + else + entity.player = entity.player or (b.kind == "platformer") + kind.attach(entity, b) + end + end + + return entity +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) + local layer + + -- 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. + 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 + 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 + kind.begin(layer) + end + end +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) + local now = authorTime() + local dt = now - lastTime + local entity + + lastTime = now + for _, entity in ipairs(AUTHOR_ORDER) do + local b + + for _, b in ipairs(entity.behaviours) do + local kind = AUTHOR.behaviours[b.kind] + + if kind ~= nil and kind.step ~= nil then + kind.step(entity, dt) + end + end + end + + if rules ~= nil then + rules() + end + + overlayClear() + for _, entity in ipairs(AUTHOR_ORDER) do + if entity.visible then + AUTHOR.looks[entity.look.kind].draw(entity) + end + 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. + +AUTHOR_KEYS = {} +AUTHOR_SWITCHES = {} + + +function authorKeyDown(keysym, scancode) + AUTHOR_KEYS[scancode] = true +end + + +function authorKeyUp(keysym, scancode) + AUTHOR_KEYS[scancode] = nil +end + + +function authorSwitchDown(what) + AUTHOR_SWITCHES[what] = true +end + + +function authorSwitchUp(what) + AUTHOR_SWITCHES[what] = nil +end diff --git a/assets/Forge/AuthorCompile.singe b/assets/Forge/AuthorCompile.singe new file mode 100644 index 000000000..7ba0b481c --- /dev/null +++ b/assets/Forge/AuthorCompile.singe @@ -0,0 +1,357 @@ +--[[ + * + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + * + * +--]] + + + +-- Turns a game description into an ordinary Singe game (PLAN section 58). +-- +-- 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. +-- +-- 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. Singe/Author.singe, the runtime that game loads, has to stay an engine support +-- file: a game handed to someone who has never installed Forge still has to run. + +dofile("Singe/Author.singe") + + +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 + return tostring(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 key + + 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])) + end + + return "{ " .. table.concat(parts, ", ") .. " }" +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. +local function emitOne(set, item, what) + local entry = set[item[1]] + + if entry == nil then + debugPrint(string.format("Author: no %s called '%s'", what, tostring(item[1]))) + return nil + end + + return entry.emit(item) +end + + +local function emitConditions(rule) + local tests = {} + local item + + for _, item in ipairs(rule.when or {}) do + local test = emitOne(AUTHOR.conditions, item, "condition") + + if test ~= nil then + tests[#tests + 1] = "(" .. test .. ")" + end + end + if #tests == 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 ") +end + + +local function emitRule(out, rule, index) + local item + + out[#out + 1] = string.format("%s-- %s", INDENT, rule.note or ("rule " .. index)) + out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule)) + 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 + end + end + out[#out + 1] = INDENT .. "end" + out[#out + 1] = "" +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 = {} + local entity + local layer + local rule + + out[#out + 1] = "-- Generated by Singe/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 = {} + local b + + 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) + end + parts[#parts + 1] = "behaviours = { " .. table.concat(made, ", ") .. " }" + end + out[#out + 1] = "authorMake({ " .. table.concat(parts, ", ") .. " })" + 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 + + +-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy. +AUTHOR_RUNTIME = "Forge/Author.singe" + + +-- 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 +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. + + +local function indentOf(depth) + return string.rep(INDENT, 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. +local function isLeaf(t) + local key + local value + + for key, value in pairs(t) do + if type(value) == "table" then + return false + end + end + + return true +end + + +-- A value written back as Lua source. A condition or an action is a mixed table -- an array part +-- naming it, plus named parameters -- so both parts have to be written or the name survives and +-- the parameters do not. The array part keeps its order; the named keys are sorted, because a +-- description that reorders itself on every save makes every diff unreadable. +local function valueSource(value, depth) + local parts = {} + local keys = {} + local count = 0 + local key + local item + local sep + local open + local close + + if type(value) ~= "table" then + return literal(value) + end + count = #value + for _, item in ipairs(value) do + parts[#parts + 1] = valueSource(item, depth + 1) + end + for key in pairs(value) do + -- Skip the array part, which has already been written in order. + if not (type(key) == "number" and key >= 1 and key <= count and key == math.floor(key)) then + keys[#keys + 1] = key + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + for _, key in ipairs(keys) do + parts[#parts + 1] = string.format("%s = %s", key, valueSource(value[key], depth + 1)) + end + if #parts == 0 then + return "{}" + end + 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 +end + + +function authorLoad(path) + local chunk, problem = loadfile(path) + + if chunk == nil then + debugPrint("Author: cannot read " .. tostring(path) .. ": " .. tostring(problem)) + return nil + end + + return chunk() +end + + +function authorSave(game, path) + local file = assert(io.open(path, "w")) + local copy = {} + local key + + -- source says where a description came from and is set by the loader, so writing it back out + -- would make a description that had been through the editor differ from one that had not. + for key in pairs(game) do + if key ~= "source" then + copy[key] = game[key] + end + end + file:write("-- Written by Singe/AuthorCompile.singe. Edit by hand or in the editor; either is fine.\n") + file:write("return " .. valueSource(copy, 0) .. "\n") + file:close() + + return path +end diff --git a/assets/Forge/Forge.rml b/assets/Forge/Forge.rml new file mode 100644 index 000000000..909c990c1 --- /dev/null +++ b/assets/Forge/Forge.rml @@ -0,0 +1,36 @@ + + + + forge + + + + +
+
≡ Entities
+
+
+
+
+ +
diff --git a/assets/Forge/Forge.singe b/assets/Forge/Forge.singe new file mode 100644 index 000000000..bbd13c506 --- /dev/null +++ b/assets/Forge/Forge.singe @@ -0,0 +1,859 @@ +--[[ + * + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + * + * +--]] + + + +-- Forge: the authoring tool (PLAN section 58). +-- +-- It is a Singe game. Nothing here is a preview: the canvas is the same overlay, at the same +-- coordinates, that the game will be played in, so what is placed is what is seen. +-- +-- The layout comes from the spike recorded in that section. Chrome is an RmlUi document, which is +-- what RmlUi is good at; the canvas beside it is drawn into the overlay and picked with +-- collidePointRect, which is what Singe has always done with a pointer and a set of rectangles. +-- They compose because the engine offers a button to the GUI first and passes on what it did not +-- use, while pointer motion is never consumed at all. + +-- Framework is wanted for SCANCODE, which the key handling below names; AuthorCompile brings in +-- the runtime and the manifest. A generated game loads Framework for itself, so the editor only +-- discovered it needed it when the rule editor started naming keys. +dofile("Singe/Framework.singe") +dofile("Forge/AuthorCompile.singe") + +local lfs = require("lfs") + + +FORGE = { + game = nil, -- The description being edited. + path = nil, + selected = nil, -- Index into game.entities. + dirty = false, + gui = nil, + document = nil, + held = nil, -- The entity being dragged. + grabX = 0, + grabY = 0, + panelX = 0, -- The chrome slides sideways so nothing is permanently under it. + panelDrag = nil, -- Offset from the panel's left edge while it is being dragged. + mode = "entities", -- or "rules". The panel is narrow; one list at a time reads better. + rule = 1, -- Selected rule. + part = 0, -- Selected condition or action within it; 0 is the rule itself. + message = "loaded" +} + +local PANEL_W = 190 -- Matches AuthorEdit.rml; the canvas starts to the right of it. +local HANDLE = 6 -- Half the size of the square drawn at an entity's own position. +local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just outside its edge. +local TAB_H = 54 +local TAB_Y = 10 + + +local function element(id) + return rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id) +end + + +-- An entity's box in overlay coordinates. A look without a size still gets one, so that a text +-- entity can be picked up and moved like anything else. +function forgeBounds(entity) + local w = (entity.look and entity.look.w) or 90 + local h = (entity.look and entity.look.h) or 20 + + return entity.x - w / 2, entity.y - h / 2, w, h +end + + +function forgeSelect(index) + FORGE.selected = index + forgeRefresh() +end + + +-- The topmost entity under a point, so overlapping things pick the one drawn last. +function forgePick(x, y) + local index + + for index = #FORGE.game.entities, 1, -1 do + local bx, by, bw, bh = forgeBounds(FORGE.game.entities[index]) + + if collidePointRect(x, y, bx, by, bw, bh) then + return index + end + end + + return nil +end + + +-- Text on its way into the document, with RML's special characters made harmless. A rule's note +-- and a Lua action's code both come from the author and both can contain them. +local function escape(text) + local out = tostring(text or "") + + out = string.gsub(out, "&", "&") + out = string.gsub(out, "<", "<") + out = string.gsub(out, ">", ">") + + return out +end + + +local function refreshEntities() + local rows = {} + local index + + for index, entity in ipairs(FORGE.game.entities) do + local class = (index == FORGE.selected) and "row selected" or "row" + + rows[#rows + 1] = string.format('
%s
', index, class, escape(entity.id)) + end + element("list").inner_rml = table.concat(rows) + for index = 1, #FORGE.game.entities do + guiSetHandler(FORGE.gui, FORGE.document, "e" .. index, "click", function() + forgeSelect(index) + end) + end + if FORGE.selected ~= nil then + local entity = FORGE.game.entities[FORGE.selected] + + element("detail").inner_rml = string.format("%s
x %d y %d
%s", escape(entity.id), entity.x or 0, entity.y or 0, escape(FORGE.message)) + else + element("detail").inner_rml = "nothing selected
" .. escape(FORGE.message) + end +end + + +-- The event sheet. The selected rule is opened in place and its conditions and actions listed +-- under it, because a rule only means anything as a whole: seeing "when" without "then" is no use. +local function refreshRules() + local rows = {} + local index + local rule + + for index, rule in ipairs(FORGE.game.rules or {}) do + local class = (index == FORGE.rule) and "row selected" or "row" + + rows[#rows + 1] = string.format('
%d. %s
', class, index, escape(rule.note or "rule")) + if index == FORGE.rule then + local parts = forgeParts(rule) + local at + + for at, part in ipairs(parts) do + local mark = (at == FORGE.part) and "part here" or "part" + + rows[#rows + 1] = string.format('
%s %s
', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item))) + end + if #parts == 0 then + rows[#rows + 1] = '
(empty)
' + end + end + end + if #rows == 0 then + rows[1] = '
no rules yet
' + end + element("list").inner_rml = table.concat(rows) + element("detail").inner_rml = string.format("rule %d of %d
%s", FORGE.rule, #(FORGE.game.rules or {}), escape(FORGE.message)) +end + + +function forgeRefresh() + if FORGE.gui == nil then + return + end + element("title").inner_rml = escape(FORGE.game.title or "Untitled") .. (FORGE.dirty and " *" or "") + element("grip").inner_rml = (FORGE.mode == "rules") and "≡ Rules" or "≡ Entities" + if FORGE.mode == "rules" then + refreshRules() + else + refreshEntities() + end +end + + +function forgeBegin(path) + FORGE.path = path + FORGE.game = authorLoad(path) + if FORGE.game == nil then + return false + end + FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight()) + FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml") + guiSetInput(FORGE.gui, true) + guiSetHandler(FORGE.gui, FORGE.document, "grip", "mousedown", function() + local x = mouseGetPosition(0) + + forgePanelGrab(x) + end) + guiSetHandler(FORGE.gui, FORGE.document, "grip", "mouseup", function() + FORGE.panelDrag = nil + FORGE.message = "panel at " .. FORGE.panelX + forgeRefresh() + end) + forgePanelTo(0) + forgeRefresh() + + return true +end + + +-- Moves the selected entity. The description is the truth; nothing is cached anywhere else, so +-- saving is only writing it back out. +function forgeMove(index, x, y) + local entity = FORGE.game.entities[index] + + entity.x = math.floor(x) + entity.y = math.floor(y) + FORGE.dirty = true + forgeRefresh() +end + + +function forgeSave() + authorSave(FORGE.game, FORGE.path) + FORGE.dirty = false + FORGE.message = "saved" + forgeRefresh() + + return FORGE.path +end + + +-- Everything a released game needs, in a directory of its own: the compiled script, the runtime +-- it loads, its games.dat, and the description it was built from so it can be opened again. +-- +-- Named export rather than release because forgeRelease is the mouse button coming up; "release" +-- is a verb here and a noun there, and the two collided. +-- +-- The runtime is copied out of Forge, which is the only place it exists: nothing of Forge ships +-- inside Singe, so a game carries the copy it needs and stands on its own from then on. +-- +-- The result is a directory --pack turns into a .game, and it runs on a machine that has never had +-- Forge on it. +function forgeExport(folder, name) + local title = FORGE.game.title or "Untitled" + local script = name or string.gsub(title, "[^%w]", "") + local dat + + if script == "" then + script = "Game" + end + -- lfs.mkdir answers false when the directory is already there, which is not a failure. + lfs.mkdir(folder) + if lfs.attributes(folder, "mode") ~= "directory" then + FORGE.message = "cannot make " .. folder + forgeRefresh() + return nil + end + + FORGE.game.source = FORGE.path + local file = io.open(folder .. "/" .. script .. ".singe", "w") + + if file == nil then + FORGE.message = "cannot write into " .. folder + forgeRefresh() + return nil + end + file:write(authorCompile(FORGE.game)) + file:close() + + if not authorCopy(AUTHOR_RUNTIME, folder .. "/Author.singe") then + FORGE.message = "could not copy the runtime" + forgeRefresh() + return nil + end + + -- The description travels with the game, so the release can be opened and edited again. + authorSave(FORGE.game, folder .. "/" .. script .. ".game") + + -- A games.dat so the menu lists it, written from what the description already knows. + dat = io.open(folder .. "/games.dat", "w") + if dat ~= nil then + dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tDATA = %q,\n\t\tVIDEO = \"Singe/menuBackground.mkv\",\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n", + title, folder .. "/" .. script .. ".singe", folder, overlayGetWidth(), overlayGetHeight())) + dat:close() + end + + FORGE.message = "released to " .. folder + forgeRefresh() + + return folder +end + + +-- Compiles what is on screen and hands back the path, so the caller can dofile it and play. +function forgeBuild(outputFile) + FORGE.game.source = FORGE.path + local file = assert(io.open(outputFile, "w")) + + file:write(authorCompile(FORGE.game)) + file:close() + FORGE.message = "built" + forgeRefresh() + + return outputFile +end + + +function forgePress(x, y) + -- Whatever is under the chrome belongs to it, wherever it has been dragged to. The GUI has + -- already had its chance at this click; checking again is belt and braces for the case where + -- an element did not claim it. + -- The tab first: it sits outside the panel, so it is reachable, and it is what moves the panel. + local tx, ty, tw, th = forgeTab() + + if collidePointRect(x, y, tx, ty, tw, th) then + forgePanelGrab(x) + return + end + if forgeOverPanel(x) then + return + end + FORGE.held = forgePick(x, y) + if FORGE.held ~= nil then + local entity = FORGE.game.entities[FORGE.held] + + FORGE.grabX = x - entity.x + FORGE.grabY = y - entity.y + forgeSelect(FORGE.held) + end +end + + +function forgeRelease() + FORGE.held = nil + FORGE.panelDrag = nil +end + + +-- Whether an x lies under the chrome as it is placed right now. +function forgeOverPanel(x) + return (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W) +end + + +-- Moves the chrome. Clamped so a panel dragged off the edge can always be got back. +function forgePanelTo(x) + local limit = overlayGetWidth() - PANEL_W + + FORGE.panelX = math.max(0, math.min(math.floor(x), limit)) + if FORGE.gui ~= nil then + element("panel").style.left = FORGE.panelX .. "px" + end +end + + +-- The tab that drags the panel, in overlay coordinates. It is deliberately drawn on the *canvas*, +-- just outside the panel, rather than being an element in the document: a press on the canvas is +-- the one pointer path this editor has been shown to receive reliably, whereas whether a document +-- gets the press at all depends on the GUI routing in _guiPointer, which the spike in PLAN section +-- 58 found silent under a window manager. Drawing the tab ourselves needs none of that. +function forgeTab() + return FORGE.panelX + PANEL_W, TAB_Y, TAB_W, TAB_H +end + + +function forgePanelGrab(x) + FORGE.panelDrag = x - FORGE.panelX + FORGE.message = "moving the panel" + forgeRefresh() +end + + +function forgeDrag(x, y) + if FORGE.panelDrag ~= nil then + forgePanelTo(x - FORGE.panelDrag) + elseif FORGE.held ~= nil then + forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY) + end +end + + +-- Draws the description. Note that this draws the *description*, not a running game: there are +-- no nodes here and no physics, which is why an entity can be dragged through a wall. +function forgeDraw(pointerX, pointerY) + local index + + colorBackground(18, 18, 26, 255) + overlayClear() + for index, entity in ipairs(FORGE.game.entities) do + local bx, by, bw, bh = forgeBounds(entity) + local look = entity.look or {} + local row + + -- Everything is drawn, including whatever is under the chrome. The panel is opaque and + -- slides, so it covers rather than hides: drag it aside and what was beneath it is there, + -- in the right place, and can be picked up. Clipping was the earlier answer and it lied. + colorForeground(look.r or 180, look.g or 180, look.b or 190, 255) + if look.kind == "text" then + colorForeground(look.r or 235, look.g or 235, look.b or 245, 255) + if (look.text or "") ~= "" then + fontPrint(entity.x, entity.y, look.text) + end + overlayBox(bx, by, bx + bw, by + bh) + else + for row = math.floor(by), math.floor(by + bh) do + overlayLine(bx, row, bx + bw, row) + end + end + if index == FORGE.selected then + colorForeground(255, 210, 70, 255) + overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2) + overlayBox(entity.x - HANDLE, entity.y - HANDLE, entity.x + HANDLE, entity.y + HANDLE) + end + end + -- The panel's drag tab. + local tx, ty, tw, th = forgeTab() + local trow + + colorForeground(70, 70, 96, 255) + for trow = ty, ty + th do + overlayLine(tx, trow, tx + tw, trow) + end + colorForeground(255, 207, 74, 255) + overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16) + overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) + overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36) + + if pointerX ~= nil then + colorForeground(255, 220, 60, 255) + overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) + overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) + end + guiDraw(FORGE.gui) +end + + +-- ===== The event sheet ======================================================================== +-- +-- Rules are where someone with little programming skill actually spends their time, so this is the +-- half of the editor that matters. It is driven by keys rather than by the pointer: the bundled +-- menu's document is driven by keys and the pad and has always worked that way, whereas whether a +-- document receives the pointer is the open question recorded in PLAN section 58. A cabinet wants +-- keys anyway. +-- +-- The vocabulary comes from AUTHOR, so a rule editor never needs changing when a condition or an +-- action is added: it lists whatever the manifest declares. + +local DEFAULTS = { + number = 0, + string = "", + boolean = true, + entity = "", + scancode = "SPACE", + switch = "SWITCH_BUTTON1", + expression = '""', + lua = "-- your Lua here", + file = "" +} + + +-- Every condition and action of a rule, flattened, so one index walks the whole thing. +function forgeParts(rule) + local parts = {} + local item + + for _, item in ipairs(rule.when or {}) do + parts[#parts + 1] = { kind = "when", item = item } + end + for _, item in ipairs(rule.act or {}) do + parts[#parts + 1] = { kind = "act", item = item } + end + + return parts +end + + +-- A new condition or action, with a value for each parameter the manifest declares, so it compiles +-- the moment it is added rather than only once every field has been filled in. +function forgeMakePart(set, name) + local entry = set[name] + local item = { name } + local key + local kind + + if entry == nil then + return nil + end + for key, kind in pairs(entry.params or {}) do + if kind == "entity" and #FORGE.game.entities > 0 then + item[key] = FORGE.game.entities[1].id + else + item[key] = DEFAULTS[kind] + end + end + + return item +end + + +function forgeRuleAdd(kindName, name) + local rule = FORGE.game.rules[FORGE.rule] + local set = (kindName == "when") and AUTHOR.conditions or AUTHOR.actions + local item = forgeMakePart(set, name) + + if (rule == nil) or (item == nil) then + FORGE.message = "no such " .. kindName + return false + end + local parts + local at + + rule[kindName] = rule[kindName] or {} + rule[kindName][#rule[kindName] + 1] = item + FORGE.dirty = true + FORGE.message = "added " .. name + + -- Select what was just added, so its parameters can be set straight away. Conditions come + -- before actions in the flattened list, so adding one shifts every action along: the new + -- part is found by identity rather than by assuming it went on the end. + parts = forgeParts(rule) + for at = 1, #parts do + if parts[at].item == item then + FORGE.part = at + end + end + forgeRefresh() + + return true +end + + +function forgePartDelete() + local rule = FORGE.game.rules[FORGE.rule] + local parts = forgeParts(rule) + local part = parts[FORGE.part] + local list + local i + + if part == nil then + return false + end + list = rule[part.kind] + for i = 1, #list do + if list[i] == part.item then + table.remove(list, i) + break + end + end + FORGE.part = math.min(FORGE.part, #forgeParts(rule)) + FORGE.dirty = true + FORGE.message = "deleted" + forgeRefresh() + + return true +end + + +-- Changes one parameter of the selected condition or action. Numbers arrive as numbers so that a +-- description keeps compiling to the same thing whether it was typed or dragged. +function forgePartSet(key, value) + local parts = forgeParts(FORGE.game.rules[FORGE.rule]) + local part = parts[FORGE.part] + + if part == nil then + return false + end + part.item[key] = value + FORGE.dirty = true + FORGE.message = key .. " = " .. tostring(value) + forgeRefresh() + + return true +end + + +function forgeRuleNew(note) + FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", when = {}, act = {} } + FORGE.rule = #FORGE.game.rules + FORGE.part = 0 + FORGE.dirty = true + FORGE.message = "rule added" + forgeRefresh() + + return FORGE.rule +end + + +function forgeRuleDelete() + if FORGE.game.rules[FORGE.rule] == nil then + return false + end + table.remove(FORGE.game.rules, FORGE.rule) + FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules)) + FORGE.part = 0 + FORGE.dirty = true + FORGE.message = "rule deleted" + forgeRefresh() + + return true +end + + +-- One condition or action as a line: its name, then its parameters in a fixed order so the same +-- rule always reads the same way. +function forgePartText(item) + local keys = {} + local out = {} + local key + + for key in pairs(item) do + if key ~= 1 then + keys[#keys + 1] = key + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + for _, key in ipairs(keys) do + out[#out + 1] = key .. " " .. tostring(item[key]) + end + + return item[1] .. (( #out > 0) and (": " .. table.concat(out, ", ")) or "") +end + + +function forgeMode(mode) + FORGE.mode = mode + forgeRefresh() +end + + +-- Keys. Point the engine's callback at this and the editor is usable without a pointer at all, +-- which is how the bundled menu has always worked and what a cabinet needs. +-- +-- TAB entities or rules +-- UP / DOWN move within the list +-- LEFT/RIGHT move the panel out of the way +-- ENTER type a value for the selected condition or action; again for its next value +-- ESC put the value back +-- N new rule DELETE delete the selected rule or part +-- S save B build +function forgeKey(keysym, scancode) + local rules = FORGE.game.rules or {} + + -- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain + -- integers, so the comparison is against .value. Comparing against the table itself is always + -- false with a real keyboard; it only appeared to work because a test passed the table. + if FORGE.editing ~= nil then + if scancode == SCANCODE.RETURN.value then + forgeEditNext() + return + end + if scancode == SCANCODE.ESCAPE.value then + forgeEditCancel() + return + end + if forgeTyped(keysym) then + return + end + elseif scancode == SCANCODE.RETURN.value then + forgeEditNext() + return + end + + if scancode == SCANCODE.TAB.value then + forgeMode((FORGE.mode == "rules") and "entities" or "rules") + + elseif scancode == SCANCODE.LEFT.value then + forgePanelTo(FORGE.panelX - 40) + elseif scancode == SCANCODE.RIGHT.value then + forgePanelTo(FORGE.panelX + 40) + + elseif FORGE.mode == "entities" then + if scancode == SCANCODE.DOWN.value then + forgeSelect(math.min((FORGE.selected or 0) + 1, #FORGE.game.entities)) + elseif scancode == SCANCODE.UP.value then + forgeSelect(math.max((FORGE.selected or 2) - 1, 1)) + end + + else + -- In the rules, up and down walk the rule and its parts as one list: past the last part + -- of a rule is the next rule, which is how it reads on screen. + if scancode == SCANCODE.DOWN.value then + local parts = forgeParts(rules[FORGE.rule] or {}) + + if FORGE.part < #parts then + FORGE.part = FORGE.part + 1 + elseif FORGE.rule < #rules then + FORGE.rule = FORGE.rule + 1 + FORGE.part = 0 + end + FORGE.lastField = nil + forgeRefresh() + elseif scancode == SCANCODE.UP.value then + if FORGE.part > 0 then + FORGE.part = FORGE.part - 1 + elseif FORGE.rule > 1 then + FORGE.rule = FORGE.rule - 1 + FORGE.part = #forgeParts(rules[FORGE.rule] or {}) + end + FORGE.lastField = nil + forgeRefresh() + elseif scancode == SCANCODE.N.value then + forgeRuleNew() + elseif scancode == SCANCODE.DELETE.value then + if FORGE.part > 0 then + forgePartDelete() + else + forgeRuleDelete() + end + end + end + + if scancode == SCANCODE.S.value then + forgeSave() + elseif scancode == SCANCODE.B.value then + forgeBuild(singeGetDataPath() .. "preview.singe") + end +end + + +-- ===== Typing a value ========================================================================= +-- +-- A rule editor that can add "when keyHeld" but cannot change SPACE to UP is most of the way to +-- useless, which is the half of the job that matters for someone with little programming skill. +-- +-- Keysyms arrive as characters the way Tools.singe takes them, so this needs nothing of the GUI: +-- ENTER starts editing the selected condition or action, each press moves to its next parameter, +-- and ESCAPE puts back what was there. + +FORGE.editing = nil -- { key = name, text = typed so far, was = the value it started as } + + +-- The parameters of a part, in the order the panel shows them, so typing walks them the same way. +function forgeFieldNames(item) + local keys = {} + local key + + for key in pairs(item) do + if key ~= 1 then + keys[#keys + 1] = key + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + + return keys +end + + +-- A typed value as the description should hold it. A number has to come back a number: "220" and +-- 220 compile to different source, and a description that changed shape because a value was +-- retyped would stop round-tripping. +local function typedValue(text, was) + local number = tonumber(text) + + if (type(was) == "number") and (number ~= nil) then + return number + end + if text == "true" then + return true + end + if text == "false" then + return false + end + if (type(was) ~= "string") and (number ~= nil) then + return number + end + + return text +end + + +function forgeEditCommit() + if FORGE.editing == nil then + return false + end + forgePartSet(FORGE.editing.key, typedValue(FORGE.editing.text, FORGE.editing.was)) + FORGE.editing = nil + + return true +end + + +function forgeEditCancel() + if FORGE.editing == nil then + return false + end + forgePartSet(FORGE.editing.key, FORGE.editing.was) + FORGE.editing = nil + FORGE.message = "unchanged" + forgeRefresh() + + return true +end + + +-- Starts editing, or moves on to the next parameter of the same part. Committing as it moves is +-- what makes ENTER, ENTER, ENTER feel like filling in a form. +function forgeEditNext() + local rule = FORGE.game.rules[FORGE.rule] + local parts = forgeParts(rule or {}) + local part = parts[FORGE.part] + local names + local at = 0 + local i + + if part == nil then + return false + end + names = forgeFieldNames(part.item) + if #names == 0 then + FORGE.message = part.item[1] .. " takes no values" + forgeRefresh() + return false + end + if FORGE.editing ~= nil then + forgeEditCommit() + end + for i = 1, #names do + if names[i] == FORGE.lastField then + at = i + end + end + at = (at % #names) + 1 + FORGE.lastField = names[at] + FORGE.editing = { key = names[at], text = "", was = part.item[names[at]] } + FORGE.message = "type a value for " .. names[at] .. ", ENTER for the next, ESC to put it back" + forgeRefresh() + + return true +end + + +-- One typed character. Point the engine's onKeyPressed at forgeKey and this is reached from there. +function forgeTyped(keysym) + if FORGE.editing == nil then + return false + end + if keysym == 8 then + FORGE.editing.text = string.sub(FORGE.editing.text, 1, -2) + elseif (keysym >= 32) and (keysym < 127) then + FORGE.editing.text = FORGE.editing.text .. string.char(keysym) + else + return false + end + -- Shown as it is typed, so the value in the list is the value being entered. + forgePartSet(FORGE.editing.key, (FORGE.editing.text == "") and FORGE.editing.was or typedValue(FORGE.editing.text, FORGE.editing.was)) + FORGE.message = FORGE.editing.key .. " = " .. FORGE.editing.text .. "_" + forgeRefresh() + + return true +end diff --git a/assets/Forge/games.dat b/assets/Forge/games.dat new file mode 100644 index 000000000..eb3c12136 --- /dev/null +++ b/assets/Forge/games.dat @@ -0,0 +1,26 @@ +-- Forge appears in the menu like anything else, so it is reached the same way a game is and packs +-- to Forge.game with -P. It needs no disc of its own; the menu background stands in, because the +-- engine wants a video and Forge draws over all of it anyway. +GAMES = { + { + TITLE = "Forge", + SCRIPT = "Forge/Forge.singe", + LEGACY_SPRITE_ARGS = false, + VIDEO = "Singe/menuBackground.mkv", + DATA = "Forge", + STRETCH = false, + NO_MOUSE = false, + RESOLUTION_X = 720, + RESOLUTION_Y = 480, + SINDEN_GUN = "", + ATTRACT = "Singe/menuBackground.mkv", + ATTRACT_START = 180, + ATTRACT_END = 359, + YEAR = 2026, + PLATFORM = "Singe", + DEVELOPER = "Kangaroo Punch Studios", + PUBLISHER = "Kangaroo Punch Studios", + GENRE = "Tool", + DESCRIPTION = "Build a game by describing it: place things, write rules, press play. Forge writes ordinary Singe Lua, so nothing it makes is locked inside it." + } +} diff --git a/assets/Menu.rml b/assets/Menu.rml index 80db6cef7..6b2a22db4 100644 --- a/assets/Menu.rml +++ b/assets/Menu.rml @@ -29,7 +29,6 @@