Working on Forge, new logo, better menus.

This commit is contained in:
Scott Duensing 2026-09-13 22:06:39 -05:00
parent c3b82d2627
commit 08415da805
46 changed files with 5767 additions and 470 deletions

213
CHANGELOG
View file

@ -26,6 +26,49 @@ API Changes
the command line keep their values. Every game in a library therefore 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. 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 name now expands to "SINGE Is Not a Game Emulator", in the banner,
the manual, INSTALL, and the Windows file description. 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 gun dead with only "No mice detected" to explain it. Only --manymouse
needs those devices now, and the message names the group. 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 - A lone .m2v played with --framefile now finds the .ogg beside it, as a
framefile's segments always have. A Daphne era elementary stream framefile's segments always have. A Daphne era elementary stream
carries no audio of its own, so lair.m2v played silently unless it was 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 copy differs from the running build. Upgrading the binary used to keep
the old Framework.singe and Menu.singe forever. the old Framework.singe and Menu.singe forever.
- The menu background video is encoded with a keyframe every second, so - Lua reads a packed game where it lies. Its file handle is a C FILE * and
the menu's jump to the game list is instant. a database row is not a file, so anything a script opened by name inside
a .game was written out to data/<game>/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 <close> 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/<game>/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 - Framefiles whose first line is a relative directory produced garbage
video paths (the directory string was freed before use); a lone "." now video paths (the directory string was freed before use); a lone "." now

View file

@ -173,6 +173,20 @@ add_custom_command(
) )
singeEmbed(${GENERATED_DIR}/icon.png ${GENERATED_DIR}/icon.h "") singeEmbed(${GENERATED_DIR}/icon.png ${GENERATED_DIR}/icon.h "")
singeEmbedImage(kangarooPunchLogo) 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) singeEmbedImage(singeLogo)
# Windows icon for the resource file. # 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/settings.cfg ${GENERATED_DIR}/settings_cfg.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.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 "") 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/Net.singe ${GENERATED_DIR}/Net_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_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/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_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 "") 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/subtitle.rcss ${GENERATED_DIR}/subtitle_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.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 # The two models the menu's intro is made of. Singe's own branding, so they belong with the engine
# menu's seeks are instant and the engine's keyframe warning stays quiet, and joined. # the way the font and the logos do -- the menu cannot reach into a game directory for them.
file(WRITE ${GENERATED_DIR}/menuBackground.txt "file ${GENERATED_DIR}/menuBackground1.mkv\nfile ${GENERATED_DIR}/menuBackground2.mkv\n") singeEmbed(${CMAKE_SOURCE_DIR}/assets/DragonModel.glb ${GENERATED_DIR}/DragonModel_glb.h "")
add_custom_command( singeEmbed(${CMAKE_SOURCE_DIR}/assets/SingeText.glb ${GENERATED_DIR}/SingeText_glb.h "")
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 # Menu background video: a recording of the backdrop the menu draws, for the machines that cannot
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 # draw it. It is not built here -- rendering it needs a GPU device and the engine itself, which is
COMMAND ${FFMPEG_TOOL} -y -loglevel error -f concat -safe 0 -i ${GENERATED_DIR}/menuBackground.txt -c copy ${GENERATED_DIR}/menuBackground.mkv # the wrong way round for a build -- so it is an asset like the models are, made by
DEPENDS "assets/Singe Engine Intro.mpg" assets/180503_01_PurpleGrid.mp4 # util/renderMenuVideo.py and re-made whenever assets/Backdrop.singe changes.
COMMENT "Building menuBackground.mkv" singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "")
VERBATIM
) # The same sound the video carries, for the renderer that draws the backdrop instead of playing it.
singeEmbed(${GENERATED_DIR}/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "") # 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. # Calibration click for the menu's audio delay screen: 20 ms of white noise with a fast fade.
add_custom_command( add_custom_command(

BIN
assets/180503_01_PurpleGrid.mp4 (Stored with Git LFS)

Binary file not shown.

623
assets/Backdrop.singe Normal file
View file

@ -0,0 +1,623 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- 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

BIN
assets/DragonModel.glb (Stored with Git LFS)

Binary file not shown.

27
assets/DragonPivots.json Normal file
View file

@ -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"]
}

546
assets/Forge/Author.singe Normal file
View file

@ -0,0 +1,546 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- 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

View file

@ -0,0 +1,357 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- 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

36
assets/Forge/Forge.rml Normal file
View file

@ -0,0 +1,36 @@
<rml>
<!--
Singe 3
Copyright (C) 2026 Scott Duensing <scott@kangaroopunch.com>
Forge's chrome. Only the panels are RmlUi; the canvas beside them is drawn
into the overlay, because that is where Singe already tracks a pointer against rectangles.
pointer-events keeps the canvas clickable through the document.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>forge</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
body { pointer-events: none; font-family: FreeSans; font-size: 13px; color: #e6e6ee; }
#panel { pointer-events: auto; position: absolute; left: 0px; top: 0px; width: 190px; height: 100%; background-color: #161620ff; }
#grip { pointer-events: auto; background-color: #2a2a3cff; padding: 6px 10px; color: #ffcf4a; font-size: 15px; }
#grip:hover { background-color: #3a3a52ff; }
#title { margin: 0px 10px 8px 10px; color: #9aa0b4; }
.row { margin: 2px 8px; padding: 4px 6px; background-color: #242433ff; }
.row:hover { background-color: #32324aff; }
.row.selected { background-color: #c03c96ff; color: #ffffff; }
.part { margin: 1px 8px 1px 18px; padding: 2px 5px; background-color: #1b1b28ff; color: #aeb4c8; font-size: 12px; }
.part.here { background-color: #4a3a6aff; color: #ffffff; }
#detail { margin: 14px 8px; padding: 6px; background-color: #10101aff; color: #b9bfd4; }
</style>
</head>
<body id="forge">
<div id="panel">
<div id="grip">&#8801; Entities</div>
<div id="title"></div>
<div id="list"></div>
<div id="detail"></div>
</div>
</body>
</rml>

859
assets/Forge/Forge.singe Normal file
View file

@ -0,0 +1,859 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Forge: the authoring tool (PLAN section 58).
--
-- It is a Singe game. Nothing here is a preview: the canvas is the same overlay, at the same
-- coordinates, that the game will be played in, so what is placed is what is seen.
--
-- The layout comes from the spike recorded in that section. Chrome is an RmlUi document, which is
-- what RmlUi is good at; the canvas beside it is drawn into the overlay and picked with
-- collidePointRect, which is what Singe has always done with a pointer and a set of rectangles.
-- They compose because the engine offers a button to the GUI first and passes on what it did not
-- use, while pointer motion is never consumed at all.
-- 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, "&", "&amp;")
out = string.gsub(out, "<", "&lt;")
out = string.gsub(out, ">", "&gt;")
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('<div id="e%d" class="%s">%s</div>', 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<br/>x %d y %d<br/>%s", escape(entity.id), entity.x or 0, entity.y or 0, escape(FORGE.message))
else
element("detail").inner_rml = "nothing selected<br/>" .. escape(FORGE.message)
end
end
-- The event sheet. The selected rule is opened in place and its conditions and actions listed
-- under it, because a rule only means anything as a whole: seeing "when" without "then" is no use.
local function refreshRules()
local rows = {}
local 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('<div class="%s">%d. %s</div>', 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('<div class="%s">%s %s</div>', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item)))
end
if #parts == 0 then
rows[#rows + 1] = '<div class="part">(empty)</div>'
end
end
end
if #rows == 0 then
rows[1] = '<div class="part">no rules yet</div>'
end
element("list").inner_rml = table.concat(rows)
element("detail").inner_rml = string.format("rule %d of %d<br/>%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 "&#8801; Rules" or "&#8801; 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

26
assets/Forge/games.dat Normal file
View file

@ -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."
}
}

View file

@ -29,7 +29,6 @@
</div> </div>
<div id="footer"> <div id="footer">
<div id="hints">Up / Down: select &nbsp; Left / Right: page &nbsp; Start or button: play &nbsp; Service: tools</div> <div id="hints">Up / Down: select &nbsp; Left / Right: page &nbsp; Start or button: play &nbsp; Service: tools</div>
<button id="start">Start</button>
</div> </div>
</div> </div>
<!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of <!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of

View file

@ -91,6 +91,42 @@ function loadGameAssets()
end end
-- Where a game's art should be when it is not there. Extracted with the other support files, so
-- it is always present whatever a game does or does not ship.
MENU_MISSING_ART = "Singe/missing.png"
-- A readable art path: the game's own when it can be opened, the missing-art picture when not.
function menuArtPath(file)
local path = tostring(file or "")
local found = (path ~= "") and lfs.attributes(path) or nil
-- Asked of the index rather than of the file. io.open, which this used to use, has to hand
-- Lua a real file it can read with the C library, so every picture it was asked about was
-- unpacked out of the .game into data/<game>/cache first -- and the menu asks about two of
-- them for every game in the library, none of which it then opens that way: the sprite loader
-- and the document's texture loader both read straight out of the database. lfs.attributes
-- goes through the same virtual filesystem and answers from the index, opening nothing.
if (found == nil) or (found.mode == "directory") then
if path ~= "" then
debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.")
end
return MENU_MISSING_ART
end
return path
end
-- How far left and right move. A page is a screenful, which is what the renderer answers, but in a
-- library smaller than a screenful every press then lands on the first game or the last and nothing
-- in between can be reached that way at all. A third of the library is never more than three
-- presses from one end to the other, whatever size it is.
function pageStep()
return math.max(1, math.min(MENU_RENDER.pageSize(), math.floor(GAME_COUNT / 3)))
end
-- The game page takes input once the backdrop has finished the intro and settled on the grid. -- The game page takes input once the backdrop has finished the intro and settled on the grid.
function menuActive() function menuActive()
return (not toolsActive()) and MENU_RENDER.backdropReady() return (not toolsActive()) and MENU_RENDER.backdropReady()
@ -128,13 +164,13 @@ function onInputPressed(what)
if GAME_SELECTED == 1 then if GAME_SELECTED == 1 then
gameSelect(GAME_COUNT) gameSelect(GAME_COUNT)
else else
gameSelect(math.max(1, GAME_SELECTED - MENU_RENDER.pageSize())) gameSelect(math.max(1, GAME_SELECTED - pageStep()))
end end
elseif what == SWITCH_RIGHT then elseif what == SWITCH_RIGHT then
if GAME_SELECTED == GAME_COUNT then if GAME_SELECTED == GAME_COUNT then
gameSelect(1) gameSelect(1)
else else
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + MENU_RENDER.pageSize())) gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
end end
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
gameStart() gameStart()
@ -177,6 +213,13 @@ function onShutdown()
unloadGameAssets() unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH) saveConfig(not SHUTDOWN_FROM_PUSH)
MENU_RENDER.finish() MENU_RENDER.finish()
if SND_INTRO_VOICE ~= SOUND_ERROR_INVALID then
-- A game started before the intro's sound had finished; it wants the speakers to itself.
soundStop(SND_INTRO_VOICE)
end
if SND_INTRO then
soundUnload(SND_INTRO)
end
if SND_CLICK then if SND_CLICK then
soundUnload(SND_CLICK) soundUnload(SND_CLICK)
end end
@ -238,20 +281,26 @@ local function loadGamesDat(source, container)
GAMES = {} GAMES = {}
end end
-- Search for games.dat files in subdirectories and inside .game databases -- Search for games.dat files in subdirectories and inside .game databases.
--
-- The extension is tested before the mode, and that order matters: the engine presents a packed
-- game through the VFS, so lfs.attributes reports a .game as a *directory*, not a file. Testing
-- the mode first sent every container down the loose-directory branch, which loads its games.dat
-- with no container -- and then every CABINET, MARQUEE and ATTRACT path in it named a file that
-- exists only inside the database. A packed library could not show a single piece of its art.
GAME_LIST = {} GAME_LIST = {}
GAME_COUNT = 0 GAME_COUNT = 0
for dir in lfs.dir(".") do for dir in lfs.dir(".") do
if dir ~= "." and dir ~= ".." then if dir ~= "." and dir ~= ".." then
local dirattr = lfs.attributes(dir) local dirattr = lfs.attributes(dir)
if dirattr.mode == "directory" then if dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir)
elseif dirattr.mode == "directory" then
for file in lfs.dir(dir .. "/.") do for file in lfs.dir(dir .. "/.") do
if file == "games.dat" then if file == "games.dat" then
loadGamesDat(dir .. "/games.dat", nil) loadGamesDat(dir .. "/games.dat", nil)
end end
end end
elseif dirattr.mode == "file" and dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir)
end end
end end
end end
@ -263,17 +312,28 @@ if GAME_COUNT == 0 then
else else
overlaySetResolution(discGetWidth(), discGetHeight()) overlaySetResolution(discGetWidth(), discGetHeight())
-- Where the engine intro ends and the grid section begins in Singe/menuBackground.mkv, and its -- Three frames of Singe/menuBackground.mkv: where the menu may take the screen, where the
-- last frame. A renderer that draws its own grid uses the first as the end of the intro and -- grid section it loops begins, and its last frame. The first two are not the same moment --
-- never reaches the second. -- the menu comes up while the music is still fading out behind it, and only then does the
DISC_GRID_START = 180 -- picture start repeating. All three come from util/renderMenuVideo.py, which prints them
DISC_LAST_FRAME = 359 -- when it makes the video; the grid section is a whole number of turns of the grid, so
-- playing it over and over has no seam in it. A renderer that draws its own grid uses none
-- of them: it knows where its own logo went.
DISC_MENU_FRAME = 222
DISC_GRID_START = 300
DISC_LAST_FRAME = 419
SHUTDOWN_FROM_PUSH = false SHUTDOWN_FROM_PUSH = false
-- The service tools share this click with the audio delay tool. -- The service tools share this click with the audio delay tool.
SND_CLICK = soundLoad("Singe/click.wav") SND_CLICK = soundLoad("Singe/click.wav")
-- The intro's sound. It belongs to the menu rather than to either renderer: one draws the
-- intro and the other plays a recording of it, and both want the same sound over the top at
-- the same moment. Keeping it out of the video is also what stops it being stored twice.
SND_INTRO = soundLoad("Singe/menuIntro.flac")
SND_INTRO_VOICE = SOUND_ERROR_INVALID
-- Load configuration -- Load configuration
SHOW_INTRO = true SHOW_INTRO = true
CONFIG_FILE = singeGetDataPath() .. "menu.dat" CONFIG_FILE = singeGetDataPath() .. "menu.dat"
@ -304,6 +364,9 @@ else
MENU_RENDER.buildList() MENU_RENDER.buildList()
MENU_RENDER.backdropBegin(SHOW_INTRO) MENU_RENDER.backdropBegin(SHOW_INTRO)
if SHOW_INTRO then
SND_INTRO_VOICE = soundPlay(SND_INTRO)
end
-- Prime the pump -- Prime the pump
gameSelect(GAME_SELECTED) gameSelect(GAME_SELECTED)

View file

@ -46,6 +46,10 @@
-- Layout, in overlay pixels: the columns' widths and the space around them; everything else -- Layout, in overlay pixels: the columns' widths and the space around them; everything else
-- follows from the overlay size in layout(). The marquee and video keep the overlay menu's -- follows from the overlay size in layout(). The marquee and video keep the overlay menu's
-- 320x75 and 320x200 proportions. -- 320x75 and 320x200 proportions.
dofile("Singe/Backdrop.singe")
-- Layout constants follow.
local MARGIN = 10 local MARGIN = 10
local GAP = 8 local GAP = 8
local LIST_W = 200 local LIST_W = 200
@ -56,228 +60,16 @@ local MARQUEE_ASPECT_H = 75
local VIDEO_ASPECT_W = 320 local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200 local VIDEO_ASPECT_H = 200
-- The backdrop: a neon grid running to a horizon under a sliced sun, drawn by the 3D scene. The
-- numbers are world units; the fog colour is also the background, so the grid fades into it rather
-- than stopping at an edge.
local GRID_CELL = 6.0 -- Spacing between grid lines.
local GRID_LINE_W = 0.12 -- Width of a line running away from the camera, at the near end.
local GRID_CROSS_W = 0.18 -- Width of a cross line, at the near end.
local GRID_SPREAD = 16.0 -- Distance over which a line doubles in width. See backdropQuad().
local GRID_HALF_X = 12 -- Lines each side of the centre.
local GRID_DEPTH = 14 -- Cross lines ahead of the camera, past where the fog ends.
local GRID_SPEED = 9.0 -- Units a second the grid moves toward the camera.
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 -- Horizontal bands cut out of the sun, widest at the bottom.
local SUN_SEGMENTS = 72 -- Segments around the sun's arc.
local HORIZ_W = 70.0 -- Half the width of the horizon bar; it has to reach both edges.
local HORIZ_H = 0.5 -- Its height. Bloom makes it read thicker than this.
local HORIZ_Z = -38.0 -- Just beyond the sun, so the sun stands in front of it.
local HORIZ_D = 2.2 -- Degrees below eye level the horizon is drawn at. The true one is
-- at eye level, infinitely far away, and the grid fades into the fog
-- well before it; dropping the line to where the grid actually ends
-- closes the gap that would otherwise sit under the bar.
local CAM_Y = 2.7 -- Eye height.
local CAM_Z = 6.0
local BLOOM_LEVEL = 0.25
local BLOOM_AMOUNT = 0.9
local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local scrollPending = false local scrollPending = false
local detailScroll = 0 -- How far down the description has walked, in pixels.
local detailClock = 0
local SCROLL_HOLD = 3.0 -- Seconds it rests at each end before moving, as in the overlay
local SCROLL_STEP = 1.1 -- renderer, and seconds a step while it is moving.
local SCROLL_LINE = 16 -- How far a step goes: a line of the description.
local backdropRoot = nil local backdropRoot = nil
local backdropGrid = nil local backdropGrid = nil
local introPlaying = false
-- A vertex on the ground plane, appended to the positions of a mesh being built.
local function backdropVertex(p, x, z)
p[#p + 1] = x
p[#p + 1] = 0
p[#p + 1] = z
end
-- One flat quad in the XZ plane, four corners in order, appended to a mesh being built.
local function backdropQuad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
backdropVertex(p, x0, z0)
backdropVertex(p, x1, z1)
backdropVertex(p, x2, z2)
backdropVertex(p, x3, z3)
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
i[#i + 1] = base + n
end
end
-- How much wider than its near end a line is at z, so that it holds its width on screen. A line of
-- constant width in the world thins as it recedes, and once it covers less than a pixel it breaks
-- into a dotted mess that crawls as the grid moves; widening it with distance is what stops that.
local function backdropSpread(z)
return 1 + (-z) / GRID_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
-- of the screen even though they are at different depths.
local function backdropHorizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
-- Half the width of the sun at height y above its middle.
local function backdropChord(y)
return math.sqrt(math.max(SUN_R * SUN_R - y * y, 0))
end
-- The grid: lines running away from the camera, and cross lines one cell beyond each end so the
-- whole thing can be slid by one cell and wrapped without a seam.
local function backdropGridMesh()
local p = {}
local i = {}
local far = -GRID_DEPTH * GRID_CELL
local wide = GRID_HALF_X * GRID_CELL
local hN = GRID_LINE_W / 2
local hF = hN * backdropSpread(far)
for n = -GRID_HALF_X, GRID_HALF_X do
local x = n * GRID_CELL
backdropQuad(p, i, x - hN, GRID_CELL, x + hN, GRID_CELL, x + hF, far, x - hF, far)
end
for n = -1, GRID_DEPTH do
local z = -n * GRID_CELL
local h = GRID_CROSS_W / 2 * backdropSpread(z)
backdropQuad(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. Cutting the
-- geometry is what puts the sun behind the horizon; letting a ground plane hide its lower half
-- instead only works while the ground reaches far enough, and the grid's gaps show through it.
local function backdropSunMesh(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
-- Builds the backdrop. Everything but the camera hangs off one node, so the whole thing can be
-- hidden with a single call while the disc plays the intro in front of it.
local function backdropBuild()
local sunCut = backdropHorizonY(SUN_Z) - SUN_Y
local neon
local sunMat
local sliceMat
local horizMat
local camera
local sun
local horizon
sceneEnable(true)
sceneSetAmbient(0, 0, 0)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
sceneSetBloom(BLOOM_LEVEL, BLOOM_AMOUNT)
sceneSetTonemap(TONEMAP_NEUTRAL)
-- Transparent until the intro is over, so the disc is what shows through the 3D layer.
sceneSetBackground(FOG_R, FOG_G, FOG_B, 0)
neon = materialNew()
materialSetColor(neon, 0, 0, 0)
materialSetEmissive(neon, 255, 45, 190)
materialSetDoubleSided(neon, true)
sunMat = materialNew()
materialSetColor(sunMat, 0, 0, 0)
materialSetEmissive(sunMat, 255, 130, 55)
materialSetDoubleSided(sunMat, true)
-- The slices are holes cut in the sun, so they are painted in the colour behind it.
sliceMat = materialNew()
materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(sliceMat, true)
materialSetDoubleSided(sliceMat, true)
-- Hotter than the grid's own magenta because the fog it sits in halves it: at the grid's
-- colour the bar sinks into the far rows instead of capping them.
horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetEmissive(horizMat, 255, 150, 235)
materialSetDoubleSided(horizMat, true)
backdropRoot = nodeNew()
nodeSetVisible(backdropRoot, false)
backdropGrid = nodeNew()
nodeSetMesh(backdropGrid, backdropGridMesh(), neon)
nodeSetParent(backdropGrid, backdropRoot)
horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, backdropHorizonY(HORIZ_Z), HORIZ_Z)
nodeSetParent(horizon, backdropRoot)
sun = nodeNew()
nodeSetMesh(sun, backdropSunMesh(sunCut), sunMat)
nodeSetPosition(sun, 0, SUN_Y, SUN_Z)
nodeSetParent(sun, backdropRoot)
-- Each slice is cut to the chord of the disc at its own height: one wider than the sun shows
-- its corners, and being opaque it also punches a rectangular hole in the horizon bar behind.
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
nodeSetMesh(slice, meshPlane(backdropChord(math.abs(y) + height / 2) * 2, height), sliceMat)
nodeSetRotation(slice, 90, 0, 0)
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
nodeSetParent(slice, backdropRoot)
end
camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
nodeSetRotation(camera, -2.0, 0, 0)
cameraSet(camera)
end
-- The intro is over: stop the disc and let the grid through.
local function backdropShow()
introPlaying = false
discPause()
sceneSetBackground(FOG_R, FOG_G, FOG_B, 255)
nodeSetVisible(backdropRoot, true)
end
function menuElement(id) function menuElement(id)
@ -298,8 +90,14 @@ end
-- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's -- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's
-- own file is reached from there through "../"; the engine's files are named as they are. -- own file is reached from there through "../"; the engine's files are named as they are.
--
-- Art that is not there shows as art that is not there, which menuArtPath decides -- a games.dat
-- naming a file that was never packed, or a path with a typo in it, is otherwise a silent hole in
-- the page, and it used to be worse than silent: an empty path took the whole engine down through
-- the texture loader. MENU_MISSING_ART is an engine file, so the "singe/" test below leaves it
-- alone.
local function menuArt(id, file) local function menuArt(id, file)
local path = tostring(file or "") local path = menuArtPath(file)
if path:sub(1, 6):lower() ~= "singe/" then if path:sub(1, 6):lower() ~= "singe/" then
path = "../" .. path path = "../" .. path
@ -345,13 +143,6 @@ local function menuRowClick(gui, document, id, event, value)
end end
local function menuStartClick(gui, document, id, event, value)
if menuActive() then
gameStart()
end
end
-- Brings the selected row into view once the document has been laid out. -- Brings the selected row into view once the document has been laid out.
local function menuScrollToSelection() local function menuScrollToSelection()
local list = menuElement("list") local list = menuElement("list")
@ -400,7 +191,7 @@ MENU_RENDER = {}
MENU_RENDER.begin = function() MENU_RENDER.begin = function()
-- The document: the size of the overlay, composited over it every frame, and first in line -- The document: the size of the overlay, composited over it every frame, and first in line
-- for the mouse and the switches so its rows and Start button can be clicked. -- for the mouse and the switches so its rows can be clicked.
GUI = guiNew(overlayGetWidth(), overlayGetHeight()) GUI = guiNew(overlayGetWidth(), overlayGetHeight())
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml") DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
guiSetInput(GUI, true) guiSetInput(GUI, true)
@ -412,46 +203,31 @@ MENU_RENDER.finish = function()
guiDelete(GUI) guiDelete(GUI)
GUI = nil GUI = nil
end end
backdropFinish()
sceneEnable(false) sceneEnable(false)
end end
-- The backdrop is the disc for the intro and the 3D scene after it. The grid section of -- The backdrop is its own file, because util/renderMenuVideo.py draws the same thing to record it.
-- 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.
MENU_RENDER.backdropBegin = function(showIntro) MENU_RENDER.backdropBegin = function(showIntro)
backdropBuild() backdropBegin(showIntro)
introPlaying = showIntro
if showIntro then
discPlay()
else
backdropShow()
end
end end
MENU_RENDER.backdrop = function() MENU_RENDER.backdrop = function()
if introPlaying and discGetFrame() >= DISC_GRID_START then
backdropShow()
end
if not introPlaying then
nodeSetPosition(backdropGrid, 0, 0, (singeGetTicks() / 1000.0 * GRID_SPEED) % GRID_CELL)
end
-- Transparent, so the scene behind the overlay is what shows. -- Transparent, so the scene behind the overlay is what shows.
colorBackground(0, 0, 0, 0) colorBackground(0, 0, 0, 0)
overlayClear() overlayClear()
backdropFrame()
end end
MENU_RENDER.backdropReady = function() MENU_RENDER.backdropReady = function()
return not introPlaying -- The menu takes the screen once the logo has left, not when a video frame number passes.
return backdropReady()
end end
-- Lays the document out from the overlay size. The list fills the left, the poster stands beside
-- it, the marquee sits over the attract video on the right, the details span both under them and
-- the key hints run along the bottom. The video rectangle is the same one videoDraw is given.
MENU_RENDER.layout = function() MENU_RENDER.layout = function()
local width = overlayGetWidth() local width = overlayGetWidth()
local height = overlayGetHeight() local height = overlayGetHeight()
@ -489,7 +265,6 @@ MENU_RENDER.buildList = function()
for index = 1, GAME_COUNT do for index = 1, GAME_COUNT do
guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick) guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick)
end end
guiSetHandler(GUI, DOCUMENT, "start", "click", menuStartClick)
end end
@ -505,6 +280,8 @@ MENU_RENDER.showGame = function(game)
guiSetValue(GUI, DOCUMENT, "port", menuLabel("Singe port", game.CREATOR) .. menuLabel("Source", game.SOURCE)) guiSetValue(GUI, DOCUMENT, "port", menuLabel("Singe port", game.CREATOR) .. menuLabel("Source", game.SOURCE))
guiSetValue(GUI, DOCUMENT, "description", menuEscape(game.DESCRIPTION)) guiSetValue(GUI, DOCUMENT, "description", menuEscape(game.DESCRIPTION))
menuElement("description").scroll_top = 0 menuElement("description").scroll_top = 0
detailScroll = 0
detailClock = 0
end end
@ -528,12 +305,41 @@ MENU_RENDER.pageSize = function()
end end
-- The description walks down by itself when there is more of it than fits, the same way the overlay
-- renderer's details do. The style sheet gives this box a scroll bar, but nothing drives one from a
-- joystick and up and down belong to the game list, so a long description was simply unreadable
-- past its first few lines.
local function menuScrollDetails()
local element = menuElement("description")
local room = element.scroll_height - element.client_height
if room <= 0 then
return
end
-- The clock starts when there is something to scroll and the menu is on screen, not when the
-- game was selected: the menu is behind the intro for the first seven seconds.
if detailClock == 0 then
detailClock = singeGetTicks()
end
local last = detailScroll >= room
local rest = (last or (detailScroll == 0)) and SCROLL_HOLD or SCROLL_STEP
if (singeGetTicks() - detailClock) >= (rest * 1000) then
detailScroll = last and 0 or math.min(detailScroll + SCROLL_LINE, room)
detailClock = singeGetTicks()
element.scroll_top = detailScroll
end
end
MENU_RENDER.frame = function() MENU_RENDER.frame = function()
-- Attract Mode Video, under the hole the document leaves for it -- Attract Mode Video, under the hole the document leaves for it
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH) videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
if scrollPending then if scrollPending then
menuScrollToSelection() menuScrollToSelection()
end end
menuScrollDetails()
guiDraw(GUI) guiDraw(GUI)
end end

View file

@ -29,31 +29,45 @@
-- two and calls it through MENU_RENDER. See the top of MenuDocument.singe for what is in it. -- two and calls it through MENU_RENDER. See the top of MenuDocument.singe for what is in it.
-- --
-- One game is on screen at a time, as it always was here, so there is no list to page through -- One game is on screen at a time, as it always was here, so there is no list to page through
-- visually: up and down move one game, left and right move ten, and the line under the marquee -- visually: up and down move one game, left and right move a page, and the line above the details
-- says where in the library the selection is. A description too long for its column is clipped -- says where in the library the selection is. An entry with more to say than fits walks down by
-- rather than scrolled, because up and down now belong to the list. -- itself, a line at a time, resting at each end: up and down belong to the library, and a cabinet
-- has no key to spare for scrolling a paragraph.
local MARGIN_X = 25 local MARGIN_X = 25
local MARGIN_Y = 25 local MARGIN_Y = 25
local VIDEO_W = 320 local MARGIN = 10 -- Around the page, and between the regions on it.
local VIDEO_H = 200 local GAP = 8
local MARQUEE_H = 75 local HINT_H = 24 -- The line of key hints along the bottom.
local SIDE_FRAC = 0.40 -- How much of the width the marquee and the attract video take.
local MARQUEE_ASPECT_W = 320 -- What the artwork and the clips are authored at; both regions keep
local MARQUEE_ASPECT_H = 75 -- these proportions whatever the overlay's size turns out to be.
local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200
local LINE_HEIGHT = 24 local LINE_HEIGHT = 24
local LABEL_W = 150 local LABEL_W = 150
local SUB_INDENT = 20 local SUB_INDENT = 20
local PAGE_ROWS = 10 local PAGE_ROWS = 10
local WRAP_BREAK = " [!wb!] " local WRAP_BREAK = " [!wb!] "
local HINTS = "Up / Down: select Left / Right: page Start or button: play Service: tools"
local font = nil local font = nil
local introDone = false local introDone = false
local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local marqueeX, marqueeY, marqueeW = 0, 0, 0 local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0
local textX, textY, textW, textH = 0, 0, 0, 0 local textX, textY, textW, textH = 0, 0, 0, 0
local hintsX, hintsY = 0, 0
local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0 local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0
local spriteCabinet = nil local spriteCabinet = nil
local spriteMarquee = nil local spriteMarquee = nil
local textSprites = {} local textSprites = {}
local textShadows = {}
local textFirst = 0 -- The first line of the details on show, which walks down by itself.
local textClock = 0
local SHADOW = 2 -- How far the shadow sits down and across from the text.
local SCROLL_HOLD = 3.0 -- Seconds the details rest at each end before moving.
local SCROLL_STEP = 1.1 -- and seconds a line while they are moving.
local textHeight = 0 local textHeight = 0
local textLimit = 0 local textLimit = 0
@ -65,6 +79,24 @@ local function field(value)
end end
-- Shrinks a sprite to fit inside a region, keeping its proportions. spriteDraw blits at the
-- sprite's own size and a games.dat's artwork is whatever size the person who drew it chose, so
-- without this a big cabinet picture is drawn straight over whatever the page put beside it. Only
-- ever smaller: blowing a small picture up to fill the space makes it soft for nothing.
local function fitSprite(handle, boxW, boxH)
local width = spriteGetWidth(handle)
local height = spriteGetHeight(handle)
if (width > 0) and (height > 0) then
local scale = math.min(boxW / width, boxH / height)
if scale < 1.0 then
spriteScale(handle, scale)
end
end
end
-- Splits text into the lines that fit the width given, measuring with the selected font. The only -- Splits text into the lines that fit the width given, measuring with the selected font. The only
-- way to measure a string is to render it, so this is slow enough to be worth doing once when -- way to measure a string is to render it, so this is slow enough to be worth doing once when
-- something changes rather than once a frame. WRAP_BREAK is a word the caller puts in to force a -- something changes rather than once a frame. WRAP_BREAK is a word the caller puts in to force a
@ -235,33 +267,48 @@ end
MENU_RENDER.backdropReady = function() MENU_RENDER.backdropReady = function()
return discGetFrame() >= DISC_GRID_START -- The menu comes up where the drawn backdrop's logo leaves, which is before the picture starts
-- repeating: the frames between the two are the grid running on while the music fades out.
return discGetFrame() >= DISC_MENU_FRAME
end end
-- The cabinet fills the left; the marquee, the attract video and the details stack down the right. -- The cabinet fills the left; the marquee, the attract video and the details stack down the right.
-- The page: the cabinet on the left, the marquee and the attract video down the right, the game's
-- details across the bottom and the key hints under them. The details used to have the video's
-- column and whatever height was left over, which at 720x480 was four lines -- the description and
-- about one field -- so most of what a games.dat says never reached the screen at all. They get
-- the full width now, and the fields are grouped the way the document renderer groups them, so the
-- whole entry fits. Everything is worked out from the overlay's size rather than written down,
-- because a game may set any size it likes.
MENU_RENDER.layout = function() MENU_RENDER.layout = function()
local width = overlayGetWidth() local width = overlayGetWidth()
local height = overlayGetHeight() local height = overlayGetHeight()
local sideW = math.floor(width * SIDE_FRAC)
local sideX = width - MARGIN - sideW
marqueeW = VIDEO_W marqueeW = sideW
marqueeX = width - MARGIN_X - marqueeW marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
marqueeY = MARGIN_Y marqueeX = sideX
marqueeY = MARGIN
videoW = VIDEO_W videoW = sideW
videoH = VIDEO_H videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
videoX = width - MARGIN_X - videoW videoX = sideX
videoY = MARGIN_Y + MARQUEE_H + MARGIN_Y videoY = marqueeY + marqueeH + GAP
textW = VIDEO_W hintsX = MARGIN
textX = width - MARGIN_X - textW hintsY = height - MARGIN - HINT_H
textY = videoY + videoH + MARGIN_Y
textH = height - MARGIN_Y - textY
cabinetX = MARGIN_X textX = MARGIN
cabinetY = MARGIN_Y textY = videoY + videoH + GAP
cabinetW = videoX - MARGIN_X - cabinetX textW = width - MARGIN - textX
cabinetH = height - MARGIN_Y - cabinetY textH = hintsY - GAP - textY
cabinetX = MARGIN
cabinetY = MARGIN
cabinetW = sideX - GAP - cabinetX
cabinetH = textY - GAP - cabinetY
end end
@ -271,25 +318,48 @@ end
MENU_RENDER.showGame = function(game) MENU_RENDER.showGame = function(game)
spriteCabinet = spriteLoad(game.CABINET) spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
spriteMarquee = spriteLoad(game.MARQUEE) spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE))
fitSprite(spriteCabinet, cabinetW, cabinetH)
fitSprite(spriteMarquee, marqueeW, marqueeH)
-- The details follow the description in one wrapped block, the way this menu has always shown -- The details, grouped the way the document renderer groups them: the short fields share a
-- them. The document menu gives them their own panel instead. -- line, and the description comes last because it is the one that can run on. It used to come
local textBox = field(game.DESCRIPTION) .. WRAP_BREAK .. WRAP_BREAK .. -- first, which meant a long description pushed every fact about the game off the bottom.
"Year: " .. field(game.YEAR) .. WRAP_BREAK .. local textBox = "Year: " .. field(game.YEAR) .. " Genre: " .. field(game.GENRE) ..
"Genre: " .. field(game.GENRE) .. WRAP_BREAK .. " Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
"Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
"Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK .. "Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK ..
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK .. WRAP_BREAK .. "Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK ..
"Singe Port: " .. field(game.CREATOR) .. WRAP_BREAK .. "Singe port: " .. field(game.CREATOR) .. " Source: " .. field(game.SOURCE) .. WRAP_BREAK ..
"Source: " .. field(game.SOURCE) WRAP_BREAK .. field(game.DESCRIPTION)
-- fontToSprite bakes the colour in, so it is set before the sprites are made rather than -- fontToSprite bakes the colour in, so it is set before the sprites are made rather than
-- before they are drawn. -- before they are drawn.
-- Twice over: black first, drawn a couple of pixels down and across behind the white, because
-- this page sits on a moving grid and white on magenta is hard to read. The document renderer
-- puts a translucent panel behind its text; nothing here can fill a rectangle, and a shadow
-- costs one more blit a line rather than one more surface a frame.
local lines = fitLines(textBox, textW)
colorForeground(0, 0, 0, 255)
for _, line in ipairs(lines) do
textShadows[#textShadows + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end
colorForeground(255, 255, 255, 255) colorForeground(255, 255, 255, 255)
for _, line in ipairs(fitLines(textBox, textW)) do for _, line in ipairs(lines) do
textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1 textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end end
-- How tall a line is, and how many of them fit under the title. Measured here rather than
-- part way through drawing, because the scroll has to know before the first line is drawn.
textHeight = 0
for _, handle in ipairs(textSprites) do
if (textHeight == 0) and (handle >= 0) then
textHeight = spriteGetHeight(handle) + 1
end
end
textLimit = (textHeight > 0) and math.max(math.floor((textH - LINE_HEIGHT) / textHeight), 1) or 1
textFirst = 0
textClock = 0
end end
@ -307,7 +377,13 @@ MENU_RENDER.hideGame = function()
spriteUnload(handle) spriteUnload(handle)
end end
end end
for _, handle in ipairs(textShadows) do
if handle >= 0 then
spriteUnload(handle)
end
end
textSprites = {} textSprites = {}
textShadows = {}
end end
@ -319,7 +395,6 @@ end
MENU_RENDER.frame = function() MENU_RENDER.frame = function()
local x = 0 local x = 0
local y = 0 local y = 0
local shown = 0
-- Cabinet image -- Cabinet image
x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5 x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5
@ -328,32 +403,56 @@ MENU_RENDER.frame = function()
-- Marquee image -- Marquee image
x = marqueeX + (marqueeW - spriteGetWidth(spriteMarquee)) * 0.5 x = marqueeX + (marqueeW - spriteGetWidth(spriteMarquee)) * 0.5
y = marqueeY + (MARQUEE_H - spriteGetHeight(spriteMarquee)) * 0.5 y = marqueeY + (marqueeH - spriteGetHeight(spriteMarquee)) * 0.5
spriteDraw(spriteMarquee, x, y) spriteDraw(spriteMarquee, x, y)
-- Attract mode video -- Attract mode video
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH) videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
-- Which game this is, since there is no list to look at -- Which game this is, since there is no list to look at. It heads the details rather than
colorForeground(255, 211, 90, 255) -- sitting over the cabinet artwork, where it was unreadable against whatever the picture was.
fontPrint(cabinetX, cabinetY, string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT)) local title = string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT)
-- Game description, clipped to the lines that fit colorForeground(0, 0, 0, 255)
y = textY fontPrint(textX + SHADOW, textY + SHADOW, title)
for _, handle in ipairs(textSprites) do colorForeground(255, 211, 90, 255)
-- Find height of font and number of lines that fit fontPrint(textX, textY, title)
if textHeight == 0 and handle >= 0 then
textHeight = spriteGetHeight(handle) -- What the game is. More of it than fits walks down a line at a time and starts over, so
textLimit = math.floor(textH / textHeight) -- everything an entry says is seen without a key to scroll it.
local last = textFirst + textLimit >= #textSprites
-- The clock starts when the details are first drawn, not when they were built: the menu is
-- behind the intro for the first seven seconds, and a page that had already scrolled itself by
-- the time anybody saw it would appear to start half way down.
if textClock == 0 then
textClock = singeGetTicks()
end end
if shown < textLimit then if #textSprites > textLimit then
if handle >= 0 then local rest = (last or (textFirst == 0)) and SCROLL_HOLD or SCROLL_STEP
spriteDraw(handle, textX, y)
end if (singeGetTicks() - textClock) >= (rest * 1000) then
y = y + textHeight + 1 textFirst = last and 0 or (textFirst + 1)
shown = shown + 1 textClock = singeGetTicks()
end end
end end
y = textY + LINE_HEIGHT
for index = textFirst + 1, math.min(textFirst + textLimit, #textSprites) do
if textSprites[index] >= 0 then
spriteDraw(textShadows[index], textX + SHADOW, y + SHADOW)
spriteDraw(textSprites[index], textX, y)
end
y = y + textHeight
end
-- Which key does what. The document renderer says the same thing along its footer; here
-- there was nothing at all, so the only way to learn that up and down move one game and left
-- and right move a page was to be told.
colorForeground(0, 0, 0, 255)
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
colorForeground(178, 196, 224, 255)
fontPrint(hintsX, hintsY, HINTS)
end end

BIN
assets/Singe Engine Intro.mpg (Stored with Git LFS)

Binary file not shown.

View file

@ -59,17 +59,15 @@ body {
color: #ffd35a; color: #ffd35a;
} }
/* Poster and marquee: the script sets an image decorator that fits the picture inside. */ /* Poster and marquee: the script sets an image decorator that fits the picture inside. No border:
cabinet art and a marquee are pictures in their own right and a frame round them fights with
whatever frame the artwork already has. */
.art { .art {
background-color: #0a122080; background-color: #0a122080;
border: 2dp #6a86b8;
border-radius: 4dp;
} }
/* The attract video shows through here; only the frame is drawn. */ /* The attract video shows through here; nothing is drawn over it. */
#video { #video {
border: 2dp #6a86b8;
border-radius: 4dp;
} }
#info { #info {
@ -106,7 +104,6 @@ body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between;
background-color: #101828d0; background-color: #101828d0;
border: 2dp #6a86b8; border: 2dp #6a86b8;
border-radius: 4dp; border-radius: 4dp;
@ -118,11 +115,6 @@ body {
color: #8fa2c4; color: #8fa2c4;
} }
#start {
font-size: 13dp;
padding: 1dp 12dp;
margin: 0;
}
/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a /* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a
page taken out of view has to be said again here or its display wins. */ page taken out of view has to be said again here or its display wins. */

BIN
assets/menuBackground.mkv (Stored with Git LFS) Normal file

Binary file not shown.

BIN
assets/menuIntro.flac (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -122,7 +122,7 @@ function buildAll() {
# resource generators embed them without complaining, so the failure only shows up at run time as a # resource generators embed them without complaining, so the failure only shows up at run time as a
# menu with no background. One sentinel is enough to tell the two apart. # menu with no background. One sentinel is enough to tell the two apart.
function checkoutHydrated() { function checkoutHydrated() {
local SENTINEL="$(dirname "$0")/assets/180503_01_PurpleGrid.mp4" local SENTINEL="$(dirname "$0")/assets/menuBackground.mkv"
# A sentinel that is not there at all is not a hydrated checkout either, and saying so beats # A sentinel that is not there at all is not a hydrated checkout either, and saying so beats
# passing quietly because the path was wrong. # passing quietly because the path was wrong.

View file

@ -454,4 +454,25 @@ ExternalProject_Add(singe
BUILD_ALWAYS ON BUILD_ALWAYS ON
) )
# Forge, packed into .builddir beside the binary so it can be copied out and tested. It is NOT
# part of the engine and nothing of it is embedded; this only puts the distributable where the
# binaries already are. Only a build that produces a runnable binary can pack it, since packing is
# the engine's own --pack, so cross builds skip it and the host build does the work.
if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux"))
file(GLOB forgeSources ${CMAKE_SOURCE_DIR}/assets/Forge/*)
set(forgePack ${CMAKE_SOURCE_DIR}/.builddir/Forge.game)
add_custom_command(
OUTPUT ${forgePack}
# Packing refuses to overwrite, and a stale pack is worse than none.
COMMAND ${CMAKE_COMMAND} -E rm -f ${forgePack}
COMMAND ${CMAKE_COMMAND} -E make_directory ${SB_PREFIX}/forgepack
# Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory.
COMMAND ${CMAKE_COMMAND} -E chdir ${SB_PREFIX}/forgepack ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${CMAKE_SOURCE_DIR}/assets/Forge ${forgePack}
DEPENDS singe ${forgeSources}
COMMENT "Packing Forge into .builddir/Forge.game"
VERBATIM
)
add_custom_target(forge ALL DEPENDS ${forgePack})
endif()
message(STATUS "Superbuild for ${KANGAROO_OS}/${KANGAROO_ARCH}: libraries into ${SB_PREFIX}, binary ${singeBinaryName}") message(STATUS "Superbuild for ${KANGAROO_OS}/${KANGAROO_ARCH}: libraries into ${SB_PREFIX}, binary ${singeBinaryName}")

View file

@ -109,7 +109,9 @@ itself: `Singe/Menu.singe` asks <<singehasgpu,singeHasGpu>> at startup and
loads one of two renderers. loads one of two renderers.
`Singe/MenuDocument.singe`:: `Singe/MenuDocument.singe`::
The RmlUi document described above, for a machine with a GPU device. The RmlUi document described above, for a machine with a GPU device. It also
draws the intro and the grid behind the menu as one continuous 3D scene, so
the disc is not used for either.
`Singe/MenuOverlay.singe`:: `Singe/MenuOverlay.singe`::
Sprites and `fontPrint` straight into the overlay, the look Singe 2 had. It Sprites and `fontPrint` straight into the overlay, the look Singe 2 had. It
@ -1015,6 +1017,9 @@ Singe/ Support files extracted by the engine
Tools.singe The menu's service tools (see The Service Tools) Tools.singe The menu's service tools (see The Service Tools)
MenuDocument.singe The menu drawn as a GUI document MenuDocument.singe The menu drawn as a GUI document
MenuOverlay.singe The menu drawn into the overlay, for a machine with no GPU MenuOverlay.singe The menu drawn into the overlay, for a machine with no GPU
Backdrop.singe The menu's intro and the grid behind it, drawn live
menuBackground.mkv The same backdrop recorded, for a machine with no GPU
menuIntro.flac Its sound, which fades out as the menu takes the screen
Menu.rml The document renderer's RmlUi document Menu.rml The document renderer's RmlUi document
menu.rcss Its style sheet, on top of gui.rcss menu.rcss Its style sheet, on top of gui.rcss
gui.rcss The shipped GUI theme gui.rcss The shipped GUI theme
@ -1112,6 +1117,21 @@ file returns `nil`, a message and the error number.
`require("name")` finds `name.lua`, `name/init.lua`, or `name.singe` under the `require("name")` finds `name.lua`, `name/init.lua`, or `name.singe` under the
script's directory, then under the game root. script's directory, then under the game root.
`io.open`, `io.lines` and `io.input` on a name that lives only inside the
database hand back a file of Singe's own rather than one of Lua's. Lua's
file handle is a C `FILE *`, and a database row is not a file, so reading
one used to mean writing it out beside the game and opening that copy;
Singe's handle reads the row where it lies. It answers to everything Lua's
does -- `read` with every format (`"a"`, `"l"`, `"L"`, `"n"`, and a byte
count), `lines`, `seek`, `close`, `flush` and `setvbuf` -- `io.type` calls
it a file, `io.read` and `io.close` take it, and it closes itself when it
is collected or when a `<close>` variable goes out of scope. The one
difference is that writing to it is an error, because what it is reading is
inside the database and the database is never written. A game writing a
file gets a real one in its data directory exactly as before, and so does
every name that is not packed. `testScripts/packedIo` is the test that
holds all of this to Lua's own behaviour; its header says how to run it.
The packer refuses a directory that has no `games.dat`, that contains The packer refuses a directory that has no `games.dat`, that contains
`controls.dat`, `Framework.singe`, a file whose extension is `exe`, `sh`, `controls.dat`, `Framework.singe`, a file whose extension is `exe`, `sh`,
`bat`, or `cmd`, or an extensionless file whose name starts with `singe`, `bat`, or `cmd`, or an extensionless file whose name starts with `singe`,
@ -3168,6 +3188,198 @@ whole screen are what slow a Raspberry Pi 4 down. The pool is capped by
`emitterSetMax` (default 1000); when it is full, no more are born until some `emitterSetMax` (default 1000); when it is full, no more are born until some
die. die.
[#authoring]
=== Describing a Game Instead of Writing One
A game can be written as a *description* -- a table of layers, entities and
rules -- and compiled into an ordinary Singe game.
`Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime
the result calls. **No part of Forge ships with Singe** -- not the editor, not
the compiler, not the runtime -- so a game built with it carries its own copy of
that runtime and stands entirely on its own. Nothing is interpreted at run time: the rules
become real Lua `if` statements, so a description costs nothing per frame on a
Raspberry Pi, and the game it produces can be opened, read and edited by hand
like any other.
There is no notion of genre anywhere in it. A game declares which of the
engine's own layers it uses, and that is the only difference between a light
gun game, a platformer and a quick-time event over video.
==== The three nouns
*Layers* are what the game draws through: `world2d` (physics in the XY plane,
drawn into the overlay), `overlay` (flat drawing over everything) and `disc`
(the video the game is played over). A game lists the ones it wants.
*Entities* are things on a layer. Each has a position, a `look` (`box`,
`sprite` or `text`) and any number of behaviours. Every entity is a node,
whether or not the 3D scene is drawing, which is what lets a 2D game built this
way run on a machine with no GPU.
*Behaviours* are bundles over engine calls that already exist: `platformer` is
the character controller (<<playernew,playerNew>> and friends), `solid` is a
static body, `drift` moves at a constant velocity. Attaching one is a line in
the description rather than code.
*Rules* are conditions and actions. Every rule is tested every frame, in the
order written, and all of a rule's conditions must hold for its actions to run.
.A description, in full
[source,lua]
----
return {
title = "One rule",
layers = { { kind = "world2d", gravity = 1500 } },
entities = {
{ id = "ground", x = 360, y = 440,
look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } },
{ id = "hero", x = 120, y = 380,
look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }
},
rules = {
{ note = "Run right",
when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } }
}
}
----
Compile it and run what comes out:
[source,lua]
----
dofile("Forge/AuthorCompile.singe")
dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe"))
----
`testScripts/author/platformer.game` and `testScripts/author/qte.game` are
worked examples, and `testScripts/scene52.singe` and `scene53.singe` compile
and play them.
==== The vocabulary, and adding to it
Conditions and actions are not built into the compiler. Each is an entry in
the `AUTHOR` table declaring its parameters and the Lua it emits, so a new kind
of game is a set of entries rather than a new release. The conditions today
are `keyHeld`, `switchHeld`, `timeBetween`, `discBetween`, `onGround`,
`touching`, `below`, `flagSet` and `once`; the actions are `run`, `jump`,
`moveTo`, `setText`, `show`, `addScore`, `setFlag`, `discTo` and `lua`.
`once` deserves a word. Rules run every frame, so anything that should happen
a single time -- a door opening, a score awarded -- needs it:
[source,lua]
----
{ when = { { "touching", entity = "hero", other = "prize" },
{ "once", tag = "prize" } },
act = { { "addScore", amount = 100 },
{ "show", entity = "prize", visible = false } } }
----
==== The way out
The `lua` action takes a line of Lua and emits it as it stands. It is there on
purpose: when a rule needs something the vocabulary cannot say, that rule drops
to Lua and the rest of the game is unaffected. A description is a convenience,
not a cage, and the compiled output is a normal game you can stop describing
and start editing whenever it suits you.
==== The editor
`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has
its own directory beside the games, appears in the menu like one, and packs to
`Forge.game` with `--pack`.
Nothing in it is a preview: the canvas is the same overlay at the same
coordinates the game will be played in, so what is placed is what is seen.
[source,lua]
----
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
forgeBegin("mygame.game")
function onOverlayUpdate()
local x, y = mouseGetPosition(0)
forgeDraw(x, y)
return OVERLAY_UPDATED
end
----
The entity list and the details are an RmlUi document; the canvas beside them is
drawn into the overlay and picked with <<collidepointrect,collidePointRect>>.
The two compose because the engine offers a button to the GUI first and passes
on what it did not use, while pointer motion is never consumed at all -- so
point `forgePress`, `forgeDrag` and `forgeRelease` at the mouse
callbacks and clicks on the panels will not reach the canvas.
`forgeSave()` writes the description back, `forgeBuild(path)`
compiles what is on screen, and `forgeMove(index, x, y)` moves an entity
without a pointer, which is how `testScripts/scene54.singe` drives it.
==== Editing the rules
The panel shows either the entities or the event sheet; `Tab` swaps them. In
the rules, the selected rule opens in place and its conditions and actions are
listed under it, because a rule only means anything whole -- a `when` without a
`then` tells you nothing.
Point <<onkeypressed,onKeyPressed>> at `forgeKey` and the whole editor
works without a pointer, which is how the bundled menu has always been driven
and what a cabinet wants:
[cols="1,4"]
|===
| `Tab` | entities or rules
| Up, Down | move through the list, and through the parts of the open rule
| Left, Right | slide the panel
| `Enter` | type a value for the selected condition or action; again for its next value
| `Esc` | put the value back
| `N` | a new rule
| `Delete` | delete the selected rule, or the selected condition or action
| `S`, `B` | save, build
|===
A typed number comes back a number rather than a string, because `100` and
`"100"` compile to different source and a description that changed shape when a
value was retyped would stop round-tripping.
From a script, `forgeRuleNew(note)`, `forgeRuleAdd("when"|"act",
name)`, `forgePartSet(key, value)` and `forgePartDelete()` do the
same work. Adding a condition selects it, so its parameters can be set at once.
The vocabulary comes from the `AUTHOR` manifest, so the rule editor never needs
changing when a condition or an action is added: it offers whatever is
declared. A new parameter gets a sensible starting value for its type, so a
rule compiles the moment it is made rather than only once every field is
filled in.
A description survives the round trip: loading one, saving it and loading it
again compiles to the same game, byte for byte. The editor depends on that and
the test asserts it.
==== Releasing a game
`forgeExport(folder, name)` writes everything a finished game needs into a
directory of its own: the compiled script, a `games.dat` so the menu lists it,
the description it was built from so it can be opened again, and **a copy of
the runtime**, taken out of Forge. `--pack` turns that directory into a `.game`
like any other, and it runs on a machine that has never had Forge on it.
Every built game finds its own directory to load that runtime from, using
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
engine was *launched* with, so a game reached by `dofile` -- a test, a
launcher, a preview -- would otherwise look beside the caller.
The panel slides. Drag the tab on its outer edge and it moves across the
window, so an entity that lives underneath it -- a score readout at `12, 12`
does -- is never permanently out of reach. `forgePanelTo(x)` moves it from
a script, and `forgeOverPanel(x)` says whether a point is currently
covered.
[#migrating] [#migrating]
=== Migrating from Singe 2.10 === Migrating from Singe 2.10

View file

@ -28,6 +28,7 @@
#include "generated/font.h" #include "generated/font.h"
#include "generated/icon.h" #include "generated/icon.h"
#include "generated/kangarooPunchLogo.h" #include "generated/kangarooPunchLogo.h"
#include "generated/missing_png.h"
#include "generated/singeLogo.h" #include "generated/singeLogo.h"
#include "generated/Framework_singe.h" #include "generated/Framework_singe.h"
#include "generated/controls_cfg.h" #include "generated/controls_cfg.h"
@ -36,6 +37,7 @@
#include "generated/Tools_singe.h" #include "generated/Tools_singe.h"
#include "generated/Net_singe.h" #include "generated/Net_singe.h"
#include "generated/Master_singe.h" #include "generated/Master_singe.h"
#include "generated/Backdrop_singe.h"
#include "generated/MenuDocument_singe.h" #include "generated/MenuDocument_singe.h"
#include "generated/MenuOverlay_singe.h" #include "generated/MenuOverlay_singe.h"
#include "generated/Menu_rml.h" #include "generated/Menu_rml.h"
@ -46,7 +48,10 @@
#include "generated/subtitle_rml.h" #include "generated/subtitle_rml.h"
#include "generated/subtitle_rcss.h" #include "generated/subtitle_rcss.h"
#include "generated/FreeSansBold_ttf.h" #include "generated/FreeSansBold_ttf.h"
#include "generated/DragonModel_glb.h"
#include "generated/SingeText_glb.h"
#include "generated/menuBackground_mkv.h" #include "generated/menuBackground_mkv.h"
#include "generated/menuIntro_flac.h"
#include "generated/click_wav.h" #include "generated/click_wav.h"
#include "generated/Manual_pdf.h" #include "generated/Manual_pdf.h"
#include "generated/COPYING.h" #include "generated/COPYING.h"

View file

@ -55,6 +55,7 @@
// and the gradient's geometry that guiFragmentGradient reads, drawn over RmlUi's geometry like // and the gradient's geometry that guiFragmentGradient reads, drawn over RmlUi's geometry like
// any other draw. The custom "shader" decorator is refused. // any other draw. The custom "shader" decorator is refused.
#include <new>
#include <math.h> #include <math.h>
#include <stdint.h> #include <stdint.h>
#include <string.h> #include <string.h>
@ -808,6 +809,10 @@ GuiRenderT::GuiRenderT(SDL_GPUDevice *gpuDevice, SDL_Window *gpuWindow) {
// An image file through RmlUi's file interface (the vfs), decoded by SDL_image and premultiplied. // An image file through RmlUi's file interface (the vfs), decoded by SDL_image and premultiplied.
// The largest a texture file may claim to be. Anything past this is a bad path, not a picture.
#define TEXTURE_BYTES_MAX (64 * 1024 * 1024)
Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, const Rml::String &source) { Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, const Rml::String &source) {
Rml::FileInterface *files = Rml::GetFileInterface(); Rml::FileInterface *files = Rml::GetFileInterface();
Rml::FileHandle file = files->Open(source); Rml::FileHandle file = files->Open(source);
@ -828,7 +833,20 @@ Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, con
files->Seek(file, 0, SEEK_END); files->Seek(file, 0, SEEK_END);
fileSize = files->Tell(file); fileSize = files->Tell(file);
files->Seek(file, 0, SEEK_SET); files->Seek(file, 0, SEEK_SET);
Rml::UniquePtr<Rml::byte[]> buffer(new Rml::byte[fileSize]);
// A size that cannot be a picture. An empty or malformed decorator path -- a games.dat naming
// art that is not there, which is a typo away at all times -- can open something that is not a
// file at all and report a nonsense length, and allocating that threw std::bad_alloc straight
// through RmlUi and ended the program. A game with a bad path must draw nothing, not die.
if ((fileSize == 0) || (fileSize > TEXTURE_BYTES_MAX)) {
files->Close(file);
return 0;
}
Rml::UniquePtr<Rml::byte[]> buffer(new (std::nothrow) Rml::byte[fileSize]);
if (!buffer) {
files->Close(file);
return 0;
}
files->Read(buffer.get(), fileSize, file); files->Read(buffer.get(), fileSize, file);
files->Close(file); files->Close(file);
surface = IMG_LoadTyped_IO(SDL_IOFromMem(buffer.get(), fileSize), true, extension.c_str()); surface = IMG_LoadTyped_IO(SDL_IOFromMem(buffer.get(), fileSize), true, extension.c_str());

View file

@ -299,6 +299,7 @@ static void _showHeader(void);
static void _showUsage(const char *name, const char *message) __attribute__((noreturn)); static void _showUsage(const char *name, const char *message) __attribute__((noreturn));
static void _startSDL(void); static void _startSDL(void);
static void _stopSDL(void); static void _stopSDL(void);
static void _stripDatabaseNames(char *path);
static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device); static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device);
static void _unpackData(const char *exePath, bool absolute); static void _unpackData(const char *exePath, bool absolute);
@ -1810,12 +1811,17 @@ static void _unpackData(const char *exePath, bool absolute) {
{ "Tools.singe", Tools_singe, Tools_singe_len }, { "Tools.singe", Tools_singe, Tools_singe_len },
{ "Net.singe", Net_singe, Net_singe_len }, { "Net.singe", Net_singe, Net_singe_len },
{ "Master.singe", Master_singe, Master_singe_len }, { "Master.singe", Master_singe, Master_singe_len },
{ "Backdrop.singe", Backdrop_singe, Backdrop_singe_len },
{ "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len }, { "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len },
{ "MenuOverlay.singe", MenuOverlay_singe, MenuOverlay_singe_len }, { "MenuOverlay.singe", MenuOverlay_singe, MenuOverlay_singe_len },
{ "Menu.rml", Menu_rml, Menu_rml_len }, { "Menu.rml", Menu_rml, Menu_rml_len },
{ "menu.rcss", menu_rcss, menu_rcss_len }, { "menu.rcss", menu_rcss, menu_rcss_len },
{ "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len }, { "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len },
{ "DragonModel.glb", DragonModel_glb, DragonModel_glb_len },
{ "SingeText.glb", SingeText_glb, SingeText_glb_len },
{ "missing.png", missing_png, missing_png_len },
{ "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len }, { "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len },
{ "menuIntro.flac", menuIntro_flac, menuIntro_flac_len },
{ "click.wav", click_wav, click_wav_len }, { "click.wav", click_wav, click_wav_len },
{ "Manual.pdf", Manual_pdf, Manual_pdf_len }, { "Manual.pdf", Manual_pdf, Manual_pdf_len },
{ "COPYING", COPYING, COPYING_len }, { "COPYING", COPYING, COPYING_len },
@ -1903,6 +1909,29 @@ ConfigT *cloneConf(const ConfigT *conf) {
} }
// Reduces every component that names a packed game to its stem, in place. A game writes to one
// data directory whether it was reached as the container the engine opened ("ActionMax.game", which
// createDataDirFor already reduces) or by a name that passes through it ("ActionMax.game/video.mkv",
// which is how a script names a file inside one). Without this the two disagreed: a packed game's
// video index went to data/ActionMax.game while everything else it wrote went to data/ActionMax,
// and the library appeared to have two of every game.
static void _stripDatabaseNames(char *path) {
const size_t extension = strlen(VFS_DATABASE_EXTENSION);
size_t i = 0;
char next = 0;
for (i = 0; path[i] != 0; i++) {
if (strncasecmp(path + i, VFS_DATABASE_EXTENSION, extension) != 0) {
continue;
}
next = path[i + extension];
if ((next == 0) || (next == '/') || (next == '\\')) {
memmove(path + i, path + i + extension, strlen(path + i + extension) + 1);
}
}
}
// Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure. // Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure.
char *createDataDir(const char *dataDirBase, const char *filename) { char *createDataDir(const char *dataDirBase, const char *filename) {
const char separator = utilGetPathSeparator(); const char separator = utilGetPathSeparator();
@ -1925,6 +1954,7 @@ char *createDataDir(const char *dataDirBase, const char *filename) {
p[1] = '_'; p[1] = '_';
} }
} }
_stripDatabaseNames(start);
path = utilCreateString("%s%s", dataDirBase, start); path = utilCreateString("%s%s", dataDirBase, start);
free(relative); free(relative);
utilFixPathSeparators(&path, true); utilFixPathSeparators(&path, true);

View file

@ -24,6 +24,7 @@
#include <string.h> #include <string.h>
#include <math.h> #include <math.h>
#include <errno.h> #include <errno.h>
#include <ctype.h>
// SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec. // SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec.
#include <openssl/sha.h> #include <openssl/sha.h>
@ -262,6 +263,9 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER
#define PLAYER_DISC -2 // materialSetVideo's "the disc": resolved every frame, since a frame file swaps players per segment #define PLAYER_DISC -2 // materialSetVideo's "the disc": resolved every frame, since a frame file swaps players per segment
#define PACKED_PERMISSIONS "r--r--r--" // lfs.attributes on a packed entry: read only #define PACKED_PERMISSIONS "r--r--r--" // lfs.attributes on a packed entry: read only
#define PACKED_BLOCK_SIZE 512 // ... and the block size it reports #define PACKED_BLOCK_SIZE 512 // ... and the block size it reports
#define LUA_VFS_FILE "SingeVfsFile" // Metatable of a Lua file that reads through the vfs
#define LUA_VFS_BUFFER 4096 // ... and how much of it that file holds at a time
#define LUA_VFS_INPUT "SingeVfsInput" // Registry slot holding one as the default input
#define LOGO_FADE_STEPS 256 #define LOGO_FADE_STEPS 256
#define LOGO_MARGIN 40 // Pixels of breathing room around a logo in the logo space #define LOGO_MARGIN 40 // Pixels of breathing room around a logo in the logo space
@ -310,9 +314,10 @@ typedef enum RenderQualityE {
// How an io function hooked through the vfs uses its file name. // How an io function hooked through the vfs uses its file name.
typedef enum IoHookModeE { typedef enum IoHookModeE {
IO_HOOK_READ = 0, IO_HOOK_INPUT = 0, // io.input: reads, and becomes the default input
IO_HOOK_OPEN = 1, // The mode string decides IO_HOOK_LINES = 1, // io.lines: reads, and hands back an iterator
IO_HOOK_WRITE = 2 IO_HOOK_OPEN = 2, // The mode string decides
IO_HOOK_WRITE = 3
} IoHookModeE; } IoHookModeE;
typedef enum OverlayResultE { typedef enum OverlayResultE {
@ -362,6 +367,32 @@ typedef struct LuaModuleS {
size_t length; size_t length;
} LuaModuleT; } LuaModuleT;
// A Lua file handle that reads through the vfs.
// Lua's own file handles are a FILE * and nothing else, so anything a script opens by name has to
// exist as a file the C library can read. For a packed game that meant writing the asset out to
// data/<game>/cache first and handing back a path to the copy -- a game's own artwork, its level
// data, anything it opened -- which is a second copy of the game on disk, written the first time
// each file is touched, and stale the moment the .game is rebuilt.
//
// Everything else in the engine already reads a packed asset in place: the sprite loader, the
// decoder, the GUI's file interface. Only Lua could not, because the io library is not ours. So
// the io library is patched here rather than worked around: io.open, io.lines and io.input hand
// back the object below when the name lives only inside a database, and it reads through
// vfsStreamOpen like everything else does. It answers to the same methods Lua's own handle does --
// read, lines, seek, close, flush, setvbuf -- and io.type, io.read and io.close know it, so a
// script cannot tell the difference except that write refuses: these are only ever opened to read.
//
// What is NOT covered, deliberately: a handle handed to a C module that expects a FILE *. Nothing
// in the engine does that, and a script that manages it gets a clear error rather than a crash.
typedef struct LuaFileS {
VfsStreamT *stream;
uint8_t buffer[LUA_VFS_BUFFER];
int64_t have; // Bytes in the buffer
int64_t at; // and how far through it the reader is.
int64_t pos; // Where the reader is in the file, buffering included.
bool closed;
} LuaFileT;
typedef struct InputNameS { typedef struct InputNameS {
const char *configName; // Table name in controls.cfg const char *configName; // Table name in controls.cfg
const char *switchName; // Constant name in scripts const char *switchName; // Constant name in scripts
@ -795,9 +826,36 @@ static void _logicalRect(SDL_FRect *rect);
static int32_t _luaCallOriginal(lua_State *L); static int32_t _luaCallOriginal(lua_State *L);
static void _luaDie(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))) __attribute__((noreturn)); static void _luaDie(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))) __attribute__((noreturn));
static int32_t _luaDofile(lua_State *L); static int32_t _luaDofile(lua_State *L);
static int32_t _luaFileClose(lua_State *L);
static int32_t _luaFileGc(lua_State *L);
static int32_t _luaFileGetc(LuaFileT *file);
static int32_t _luaFileIterate(lua_State *L);
static int32_t _luaFileLines(lua_State *L);
static int32_t _luaFileLinesFrom(lua_State *L, int32_t index, bool closing);
static int32_t _luaFileNoop(lua_State *L);
static LuaFileT *_luaFileOpen(lua_State *L, int32_t index);
static bool _luaFilePush(lua_State *L, const char *name);
static int32_t _luaFileRead(lua_State *L);
static void _luaFileReadAll(lua_State *L, LuaFileT *file);
static bool _luaFileReadCount(lua_State *L, LuaFileT *file, int64_t count);
static bool _luaFileReadFormat(lua_State *L, LuaFileT *file, int32_t index);
static bool _luaFileReadLine(lua_State *L, LuaFileT *file, bool keepEnding);
static bool _luaFileReadNumber(lua_State *L, LuaFileT *file);
static void _luaFileRegister(lua_State *L);
static int32_t _luaFileSearcher(lua_State *L); static int32_t _luaFileSearcher(lua_State *L);
static int32_t _luaFileSeek(lua_State *L);
static LuaFileT *_luaFileTest(lua_State *L, int32_t index);
static int32_t _luaFileToString(lua_State *L);
static void _luaFileUngetc(LuaFileT *file);
static int32_t _luaFileWrite(lua_State *L);
static char *_luaFormat(lua_State *L, const char *method, const char *fmt, va_list args); static char *_luaFormat(lua_State *L, const char *method, const char *fmt, va_list args);
static int32_t _luaIoClose(lua_State *L);
static void _luaIoForgetInput(lua_State *L);
static LuaFileT *_luaIoInput(lua_State *L);
static int32_t _luaIoHook(lua_State *L); static int32_t _luaIoHook(lua_State *L);
static int32_t _luaIoRead(lua_State *L);
static int32_t _luaIoSetInput(lua_State *L);
static int32_t _luaIoType(lua_State *L);
static int32_t _luaLoadfile(lua_State *L); static int32_t _luaLoadfile(lua_State *L);
static int32_t _luaLoadFile(lua_State *L, const char *name, const char *mode, bool watch); static int32_t _luaLoadFile(lua_State *L, const char *name, const char *mode, bool watch);
static int32_t _luaopenLfs(lua_State *L); static int32_t _luaopenLfs(lua_State *L);
@ -3431,12 +3489,12 @@ static int32_t _guiQueueDraw(lua_State *L, const char *method, bool screen) {
} }
// Both passes of _drawGuis are done: this frame's GUIs become the ones a pointer may land on, and // Both passes of _drawGuis are done: this frame's GUIs become the ones a pointer may land on.
// the queue is empty for the next. // The queue itself is not emptied here -- the script's next turn does that -- so the same GUIs are
// drawn again on the refreshes between script frames instead of vanishing.
static void _guisShown(void) { static void _guisShown(void) {
memcpy(_global.guiShown, _global.guiDraws, sizeof(GuiDrawT) * (size_t)_global.guiDrawCount); memcpy(_global.guiShown, _global.guiDraws, sizeof(GuiDrawT) * (size_t)_global.guiDrawCount);
_global.guiShownCount = _global.guiDrawCount; _global.guiShownCount = _global.guiDrawCount;
_global.guiDrawCount = 0;
} }
@ -3534,7 +3592,19 @@ static void _installFileHooks(lua_State *L) {
static const struct { static const struct {
const char *name; const char *name;
IoHookModeE mode; IoHookModeE mode;
} ioHooks[] = { { "input", IO_HOOK_READ }, { "lines", IO_HOOK_READ }, { "open", IO_HOOK_OPEN }, { "output", IO_HOOK_WRITE } }; } ioHooks[] = {
{ "close", IO_HOOK_OPEN }, // Only the handle matters to these three; the mode is unused.
{ "input", IO_HOOK_INPUT },
{ "lines", IO_HOOK_LINES },
{ "open", IO_HOOK_OPEN },
{ "output", IO_HOOK_WRITE },
{ "read", IO_HOOK_OPEN },
{ "type", IO_HOOK_OPEN }
};
static const struct {
const char *name;
lua_CFunction hook;
} handleHooks[] = { { "close", _luaIoClose }, { "read", _luaIoRead }, { "type", _luaIoType } };
size_t i = 0; size_t i = 0;
lua_getglobal(L, "dofile"); lua_getglobal(L, "dofile");
@ -3543,6 +3613,7 @@ static void _installFileHooks(lua_State *L) {
lua_getglobal(L, "loadfile"); lua_getglobal(L, "loadfile");
lua_pushcclosure(L, _luaLoadfile, 1); lua_pushcclosure(L, _luaLoadfile, 1);
lua_setglobal(L, "loadfile"); lua_setglobal(L, "loadfile");
_luaFileRegister(L);
lua_getglobal(L, "io"); lua_getglobal(L, "io");
for (i = 0; i < sizeof(ioHooks) / sizeof(ioHooks[0]); i++) { for (i = 0; i < sizeof(ioHooks) / sizeof(ioHooks[0]); i++) {
lua_getfield(L, -1, ioHooks[i].name); lua_getfield(L, -1, ioHooks[i].name);
@ -3550,6 +3621,11 @@ static void _installFileHooks(lua_State *L) {
lua_pushcclosure(L, _luaIoHook, 2); lua_pushcclosure(L, _luaIoHook, 2);
lua_setfield(L, -2, ioHooks[i].name); lua_setfield(L, -2, ioHooks[i].name);
} }
for (i = 0; i < sizeof(handleHooks) / sizeof(handleHooks[0]); i++) {
lua_getfield(L, -1, handleHooks[i].name);
lua_pushcclosure(L, handleHooks[i].hook, 1);
lua_setfield(L, -2, handleHooks[i].name);
}
lua_pop(L, 1); lua_pop(L, 1);
} }
@ -4093,6 +4169,324 @@ static int32_t _luaDofile(lua_State *L) {
} }
static int32_t _luaFileClose(lua_State *L) {
LuaFileT *file = (LuaFileT *)luaL_checkudata(L, 1, LUA_VFS_FILE);
if (!file->closed) {
vfsStreamClose(file->stream);
file->stream = NULL;
file->closed = true;
}
lua_pushboolean(L, 1);
return 1;
}
static int32_t _luaFileGc(lua_State *L) {
LuaFileT *file = (LuaFileT *)luaL_checkudata(L, 1, LUA_VFS_FILE);
if (!file->closed) {
vfsStreamClose(file->stream);
file->stream = NULL;
file->closed = true;
}
return 0;
}
// One byte, or -1 at the end. The buffer is what keeps a line at a time from costing a database
// read a character.
static int32_t _luaFileGetc(LuaFileT *file) {
if (file->at >= file->have) {
file->have = vfsStreamRead(file->stream, file->buffer, LUA_VFS_BUFFER);
file->at = 0;
if (file->have <= 0) {
file->have = 0;
return -1;
}
}
file->pos++;
return file->buffer[file->at++];
}
// The iterator file:lines() and io.lines() hand back. Upvalues: the file, whether to close it at
// the end, and the formats.
static int32_t _luaFileIterate(lua_State *L) {
LuaFileT *file = (LuaFileT *)lua_touserdata(L, lua_upvalueindex(1));
bool closing = lua_toboolean(L, lua_upvalueindex(2));
int32_t formats = (int32_t)lua_tointeger(L, lua_upvalueindex(3));
int32_t i = 0;
if (file->closed) {
return luaL_error(L, "file is already closed");
}
if (formats == 0) {
if (_luaFileReadLine(L, file, false)) {
return 1;
}
} else {
for (i = 0; i < formats; i++) {
lua_pushvalue(L, lua_upvalueindex(4 + i));
lua_replace(L, 1);
lua_settop(L, 1);
if (!_luaFileReadFormat(L, file, 1)) {
lua_settop(L, 0);
break;
}
}
if (i == formats) {
return formats;
}
}
if (closing) {
vfsStreamClose(file->stream);
file->stream = NULL;
file->closed = true;
}
lua_pushnil(L);
return 1;
}
static int32_t _luaFileLines(lua_State *L) {
_luaFileOpen(L, 1);
return _luaFileLinesFrom(L, 1, false);
}
// Builds that iterator over the file at index, with the formats that follow it.
static int32_t _luaFileLinesFrom(lua_State *L, int32_t index, bool closing) {
int32_t formats = lua_gettop(L) - index;
int32_t i = 0;
lua_pushvalue(L, index);
lua_pushboolean(L, closing);
lua_pushinteger(L, formats);
for (i = 0; i < formats; i++) {
lua_pushvalue(L, index + 1 + i);
}
lua_pushcclosure(L, _luaFileIterate, 3 + formats);
return 1;
}
// Buffering is ours to do and there is nothing to flush; both are here so the object answers
// everything Lua's does.
static int32_t _luaFileNoop(lua_State *L) {
_luaFileOpen(L, 1);
lua_pushvalue(L, 1);
return 1;
}
static LuaFileT *_luaFileOpen(lua_State *L, int32_t index) {
LuaFileT *file = (LuaFileT *)luaL_checkudata(L, index, LUA_VFS_FILE);
if (file->closed) {
luaL_error(L, "attempt to use a closed file");
}
return file;
}
// Opens name through the vfs and pushes the handle, or nil and a message the Lua way.
static bool _luaFilePush(lua_State *L, const char *name) {
VfsStreamT *stream = vfsStreamOpen(name);
LuaFileT *file = NULL;
if (stream == NULL) {
lua_pushnil(L);
lua_pushfstring(L, "%s: No such file or directory", name);
lua_pushinteger(L, ENOENT);
return false;
}
file = (LuaFileT *)lua_newuserdatauv(L, sizeof(LuaFileT), 0);
memset(file, 0, sizeof(*file));
file->stream = stream;
luaL_setmetatable(L, LUA_VFS_FILE);
return true;
}
// file:read(...) -- every format in turn, stopping at the first that finds nothing.
static int32_t _luaFileRead(lua_State *L) {
LuaFileT *file = _luaFileOpen(L, 1);
int32_t formats = lua_gettop(L) - 1;
int32_t i = 0;
if (formats == 0) {
if (!_luaFileReadLine(L, file, false)) {
lua_pushnil(L);
}
return 1;
}
for (i = 0; i < formats; i++) {
if (!_luaFileReadFormat(L, file, i + 2)) {
lua_pushnil(L);
return i + 1;
}
}
return formats;
}
// Reads until the end and pushes what there was. Always a string, empty at the end of the file,
// which is what Lua's "a" does.
static void _luaFileReadAll(lua_State *L, LuaFileT *file) {
luaL_Buffer out;
int32_t c = 0;
luaL_buffinit(L, &out);
while ((c = _luaFileGetc(file)) >= 0) {
luaL_addchar(&out, (char)c);
}
luaL_pushresult(&out);
}
// At most count bytes. False at the end of the file unless none were asked for, which Lua uses to
// test for it.
static bool _luaFileReadCount(lua_State *L, LuaFileT *file, int64_t count) {
luaL_Buffer out;
int64_t read = 0;
int32_t c = 0;
luaL_buffinit(L, &out);
while ((read < count) && ((c = _luaFileGetc(file)) >= 0)) {
luaL_addchar(&out, (char)c);
read++;
}
luaL_pushresult(&out);
if ((read == 0) && (count > 0)) {
lua_pop(L, 1);
return false;
}
return true;
}
// One of read's formats. Answers false when there was nothing left to read it from.
static bool _luaFileReadFormat(lua_State *L, LuaFileT *file, int32_t index) {
const char *format = NULL;
if (lua_type(L, index) == LUA_TNUMBER) {
return _luaFileReadCount(L, file, (int64_t)luaL_checkinteger(L, index));
}
format = luaL_checkstring(L, index);
if (*format == '*') { // 5.2 wrote the formats with a star; 5.4 allows both.
format++;
}
switch (*format) {
case 'l':
return _luaFileReadLine(L, file, false);
case 'L':
return _luaFileReadLine(L, file, true);
case 'a':
_luaFileReadAll(L, file);
return true;
case 'n':
return _luaFileReadNumber(L, file);
default:
luaL_argerror(L, index, "invalid format");
}
return false;
}
// A line, with or without its ending. False at the end of the file, where Lua answers nil.
static bool _luaFileReadLine(lua_State *L, LuaFileT *file, bool keepEnding) {
luaL_Buffer out;
int32_t c = 0;
bool found = false;
luaL_buffinit(L, &out);
while ((c = _luaFileGetc(file)) >= 0) {
found = true;
if (c == '\n') {
if (keepEnding) {
luaL_addchar(&out, (char)c);
}
break;
}
luaL_addchar(&out, (char)c);
}
luaL_pushresult(&out);
if (!found) {
lua_pop(L, 1);
}
return found;
}
// A number, the way Lua reads one: whitespace, then as much as still looks like a numeral. What
// is accepted is whatever lua_stringtonumber accepts, so hexadecimal and exponents come along.
static bool _luaFileReadNumber(lua_State *L, LuaFileT *file) {
char text[64];
size_t length = 0;
int32_t c = 0;
do {
c = _luaFileGetc(file);
} while ((c >= 0) && isspace(c));
while ((c >= 0) && (length < sizeof(text) - 1) && (isalnum(c) || (strchr("+-.", c) != NULL))) {
text[length++] = (char)c;
c = _luaFileGetc(file);
}
if (c >= 0) {
_luaFileUngetc(file);
}
text[length] = 0;
if ((length == 0) || (lua_stringtonumber(L, text) == 0)) {
return false;
}
return true;
}
// The metatable, once.
static void _luaFileRegister(lua_State *L) {
static const luaL_Reg methods[] = {
{ "close", _luaFileClose },
{ "flush", _luaFileNoop },
{ "lines", _luaFileLines },
{ "read", _luaFileRead },
{ "seek", _luaFileSeek },
{ "setvbuf", _luaFileNoop },
{ "write", _luaFileWrite },
{ NULL, NULL }
};
luaL_newmetatable(L, LUA_VFS_FILE);
lua_pushvalue(L, -1);
lua_setfield(L, -2, "__index");
luaL_setfuncs(L, methods, 0);
lua_pushcfunction(L, _luaFileGc);
lua_setfield(L, -2, "__gc");
lua_pushcfunction(L, _luaFileClose);
lua_setfield(L, -2, "__close");
lua_pushcfunction(L, _luaFileToString);
lua_setfield(L, -2, "__tostring");
lua_pushliteral(L, "FILE*");
lua_setfield(L, -2, "__name");
lua_pop(L, 1);
}
// require() of a game's own module: name.lua, name/init.lua, or name.singe under the script's // require() of a game's own module: name.lua, name/init.lua, or name.singe under the script's
// directory, then relative to the game root. // directory, then relative to the game root.
static int32_t _luaFileSearcher(lua_State *L) { static int32_t _luaFileSearcher(lua_State *L) {
@ -4140,6 +4534,76 @@ static int32_t _luaFileSearcher(lua_State *L) {
} }
// file:seek([whence [, offset]]) -- "set", "cur" or "end", as Lua's does.
static int32_t _luaFileSeek(lua_State *L) {
static const char *const names[] = { "set", "cur", "end", NULL };
LuaFileT *file = _luaFileOpen(L, 1);
int32_t whence = luaL_checkoption(L, 2, "cur", names);
int64_t offset = (int64_t)luaL_optinteger(L, 3, 0);
int64_t target = 0;
switch (whence) {
case 0:
target = offset;
break;
case 1:
target = file->pos + offset;
break;
default:
target = vfsStreamSize(file->stream) + offset;
break;
}
if (target < 0) {
lua_pushnil(L);
lua_pushliteral(L, "Invalid argument");
return 2;
}
if (vfsStreamSeek(file->stream, target, SEEK_SET) < 0) {
lua_pushnil(L);
lua_pushliteral(L, "seek failed");
return 2;
}
file->pos = target;
file->have = 0;
file->at = 0;
lua_pushinteger(L, (lua_Integer)target);
return 1;
}
// The handle, if that is what is at index; NULL for anything else.
static LuaFileT *_luaFileTest(lua_State *L, int32_t index) {
return (LuaFileT *)luaL_testudata(L, index, LUA_VFS_FILE);
}
static int32_t _luaFileToString(lua_State *L) {
LuaFileT *file = (LuaFileT *)luaL_checkudata(L, 1, LUA_VFS_FILE);
lua_pushfstring(L, file->closed ? "file (closed)" : "file (%p)", (void *)file);
return 1;
}
static void _luaFileUngetc(LuaFileT *file) {
if (file->at > 0) {
file->at--;
file->pos--;
}
}
// Writing is not a thing these do: they are handed out for reading a packed asset, and a packed
// asset is read only. A game writing to its own directory gets a real file, as it always did.
static int32_t _luaFileWrite(lua_State *L) {
_luaFileOpen(L, 1);
return luaL_error(L, "file is read only: it lives inside a .game");
}
// Formats "line:method: message" for tracing and errors. Caller frees. // Formats "line:method: message" for tracing and errors. Caller frees.
static char *_luaFormat(lua_State *L, const char *method, const char *fmt, va_list args) { static char *_luaFormat(lua_State *L, const char *method, const char *fmt, va_list args) {
lua_Debug ar; lua_Debug ar;
@ -4164,18 +4628,115 @@ static char *_luaFormat(lua_State *L, const char *method, const char *fmt, va_li
} }
// io.close([file]) -- ours if that is what it was given, or what io.input was last set to.
static int32_t _luaIoClose(lua_State *L) {
if (lua_gettop(L) == 0) {
if (_luaIoInput(L) == NULL) {
return _luaCallOriginal(L);
}
lua_getfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT);
_luaIoForgetInput(L);
} else if (_luaFileTest(L, 1) == NULL) {
return _luaCallOriginal(L);
}
return _luaFileClose(L);
}
// Whatever io.input was last given, if it was one of ours. The stack is left as it was found.
static void _luaIoForgetInput(lua_State *L) {
lua_pushnil(L);
lua_setfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT);
}
static LuaFileT *_luaIoInput(lua_State *L) {
LuaFileT *file = NULL;
if (lua_getfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT) != LUA_TNIL) {
file = _luaFileTest(L, -1);
}
lua_pop(L, 1);
return file;
}
// io.read(...) -- from ours when io.input was given one, otherwise from Lua's own default input.
static int32_t _luaIoRead(lua_State *L) {
if (_luaIoInput(L) == NULL) {
return _luaCallOriginal(L);
}
lua_getfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT);
lua_insert(L, 1);
return _luaFileRead(L);
}
// The handle at 1 becomes the default input, and answers as io.input's result. Lua keeps its own
// default in the registry and its own functions would take ours for a FILE *, so ours is kept
// beside it and the three functions that read the default look here first.
static int32_t _luaIoSetInput(lua_State *L) {
lua_settop(L, 1);
lua_pushvalue(L, 1);
lua_setfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT);
return 1;
}
// io.type(x) -- ours answer as files, because to a script that is what they are.
static int32_t _luaIoType(lua_State *L) {
LuaFileT *file = _luaFileTest(L, 1);
if (file == NULL) {
return _luaCallOriginal(L);
}
lua_pushstring(L, file->closed ? "closed file" : "file");
return 1;
}
// io.open and friends with a name resolved through the vfs. Upvalues: the original, and how the name is used. // io.open and friends with a name resolved through the vfs. Upvalues: the original, and how the name is used.
static int32_t _luaIoHook(lua_State *L) { static int32_t _luaIoHook(lua_State *L) {
IoHookModeE mode = (IoHookModeE)lua_tointeger(L, lua_upvalueindex(2)); IoHookModeE mode = (IoHookModeE)lua_tointeger(L, lua_upvalueindex(2));
const char *modeString = NULL; const char *modeString = NULL;
const char *name = NULL;
char *path = NULL; char *path = NULL;
bool writing = (mode == IO_HOOK_WRITE); bool writing = (mode == IO_HOOK_WRITE);
if (lua_type(L, 1) == LUA_TSTRING) { if (lua_type(L, 1) == LUA_TSTRING) {
name = lua_tostring(L, 1);
if (mode == IO_HOOK_OPEN) { if (mode == IO_HOOK_OPEN) {
modeString = luaL_optstring(L, 2, "r"); modeString = luaL_optstring(L, 2, "r");
writing = (strpbrk(modeString, "wa+") != NULL); writing = (strpbrk(modeString, "wa+") != NULL);
} }
// A name that lives only inside the .game is read where it lies. Everything else -- a
// loose file, anything being written -- is a real file and goes to the original.
if (!writing && vfsIsPacked(name)) {
if (!_luaFilePush(L, name)) {
if (mode != IO_HOOK_OPEN) {
return luaL_error(L, "%s: No such file or directory", name);
}
return 3;
}
lua_replace(L, 1);
if (mode == IO_HOOK_LINES) {
return _luaFileLinesFrom(L, 1, true);
}
if (mode == IO_HOOK_INPUT) {
return _luaIoSetInput(L);
}
lua_settop(L, 1);
return 1;
}
if (mode == IO_HOOK_INPUT) {
// A real file takes over as the default input, so ours stops being it.
_luaIoForgetInput(L);
}
path = vfsFilePath(lua_tostring(L, 1), writing); path = vfsFilePath(lua_tostring(L, 1), writing);
if (path == NULL) { if (path == NULL) {
// A name inside a packed game that resolves nowhere: io.open reports it the Lua way, the others raise. // A name inside a packed game that resolves nowhere: io.open reports it the Lua way, the others raise.
@ -4189,6 +4750,22 @@ static int32_t _luaIoHook(lua_State *L) {
lua_pushstring(L, path); lua_pushstring(L, path);
lua_replace(L, 1); lua_replace(L, 1);
free(path); free(path);
} else if (mode == IO_HOOK_LINES) {
// io.lines() with no name reads the default input, which may be one of ours.
if (_luaIoInput(L) != NULL) {
lua_getfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT);
lua_insert(L, 1);
return _luaFileLinesFrom(L, 1, false);
}
} else if (mode == IO_HOOK_INPUT) {
if (_luaFileTest(L, 1) != NULL) {
return _luaIoSetInput(L);
}
if (lua_isnoneornil(L, 1) && (_luaIoInput(L) != NULL)) {
lua_getfield(L, LUA_REGISTRYINDEX, LUA_VFS_INPUT);
return 1;
}
_luaIoForgetInput(L);
} }
return _luaCallOriginal(L); return _luaCallOriginal(L);
@ -15953,8 +16530,18 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
} }
// Call game code, unless the engine has it paused. // Call game code, unless the engine has it paused.
//
// The queues guiDraw and emitterDraw fill are emptied here, at the start of the script's
// turn, rather than after the frame is rendered. Both are documented as lasting "one
// frame", and a frame means one onOverlayUpdate: that call is throttled to FRAME_TICK_MS
// while the display refreshes as often as it can, and once a 3D scene is enabled it
// refreshes continuously. Emptying them per rendered frame therefore drew the GUIs and
// the 2D particles only on the frames the script happened to run on, which is a menu that
// blinks over a grid that does not.
if (!_global.frozen && (utilTicks() > frameClock)) { if (!_global.frozen && (utilTicks() > frameClock)) {
intReturn = OVERLAY_NOT_UPDATED; intReturn = OVERLAY_NOT_UPDATED;
_global.guiDrawCount = 0;
particlesClearQueue2D();
_callLua("onOverlayUpdate", ">i", &intReturn); _callLua("onOverlayUpdate", ">i", &intReturn);
if (intReturn == OVERLAY_UPDATED) { if (intReturn == OVERLAY_UPDATED) {
_global.refreshDisplay = true; _global.refreshDisplay = true;
@ -16026,7 +16613,6 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
_subtitleQueue(); _subtitleQueue();
_drawGuis(&_global.videoRect, false); _drawGuis(&_global.videoRect, false);
_drawParticles2D(PARTICLE_OVER, &_global.videoRect); _drawParticles2D(PARTICLE_OVER, &_global.videoRect);
particlesClearQueue2D();
if ((_global.bezelTexture != NULL) && _global.bezelInFront && !_global.overlayOnTop) { if ((_global.bezelTexture != NULL) && _global.bezelInFront && !_global.overlayOnTop) {
_bezelDraw(); _bezelDraw();
} }

View file

@ -55,7 +55,6 @@
#define fileTell ftello #define fileTell ftello
#endif #endif
#define CACHE_DIRECTORY "cache"
#define OVERLAY_DIRECTORY "files" #define OVERLAY_DIRECTORY "files"
#define LIST_INITIAL_CAPACITY 64 #define LIST_INITIAL_CAPACITY 64
#define MAIN_SCHEMA "main" #define MAIN_SCHEMA "main"
@ -65,7 +64,6 @@ typedef struct DatabaseS {
char *path; // Database file as named by the caller char *path; // Database file as named by the caller
char *loose; // Sibling directory holding loose overrides, trailing separator char *loose; // Sibling directory holding loose overrides, trailing separator
char *overlay; // Copy-on-write directory under the data directory, trailing separator char *overlay; // Copy-on-write directory under the data directory, trailing separator
char *cache; // Where read-only io opens of packed assets are unpacked to; never searched
char *gameDir; // Lower case own-directory prefix the packer recorded, or NULL char *gameDir; // Lower case own-directory prefix the packer recorded, or NULL
int64_t chunkBytes; int64_t chunkBytes;
sqlite3 *db; sqlite3 *db;
@ -95,7 +93,6 @@ typedef struct TargetS {
char *path; // Filesystem path, or the loose override candidate for a packed name char *path; // Filesystem path, or the loose override candidate for a packed name
char *key; // Normalised key inside the database char *key; // Normalised key inside the database
char *overlay; // Overlay path for the key char *overlay; // Overlay path for the key
char *cache; // Cache path for the key
} TargetT; } TargetT;
struct VfsStreamS { struct VfsStreamS {
@ -115,7 +112,6 @@ static bool _assetDirectory(DatabaseT *db, const char *key);
static bool _assetExists(DatabaseT *db, const char *key); static bool _assetExists(DatabaseT *db, const char *key);
static bool _assetSize(DatabaseT *db, const char *key, int64_t *size); static bool _assetSize(DatabaseT *db, const char *key, int64_t *size);
static char *_bindListRange(DatabaseT *db, const char *key); static char *_bindListRange(DatabaseT *db, const char *key);
static bool _cacheCurrent(const TargetT *target);
static bool _climbsAbove(const char *path); static bool _climbsAbove(const char *path);
static void _databaseClose(DatabaseT *db); static void _databaseClose(DatabaseT *db);
static DatabaseT *_databaseOpen(const char *path); static DatabaseT *_databaseOpen(const char *path);
@ -136,7 +132,6 @@ static uint8_t *_readAsset(DatabaseT *db, const char *key, size_t *bytes, boo
static bool _readChunk(DatabaseT *db, const char *key, int64_t index, uint8_t *buffer, int64_t capacity, int64_t *length); static bool _readChunk(DatabaseT *db, const char *key, int64_t index, uint8_t *buffer, int64_t capacity, int64_t *length);
static bool _resolve(const char *name, TargetT *target); static bool _resolve(const char *name, TargetT *target);
static void _targetFree(TargetT *target); static void _targetFree(TargetT *target);
static bool _writeFile(const char *path, const uint8_t *data, size_t bytes);
static DatabaseT *_databases = NULL; static DatabaseT *_databases = NULL;
@ -196,25 +191,6 @@ static char *_bindListRange(DatabaseT *db, const char *key) {
} }
// True when the cached copy of a packed asset is the same size as the asset and no older than the database.
static bool _cacheCurrent(const TargetT *target) {
int64_t cacheSize = 0;
int64_t cacheTime = 0;
int64_t assetSize = 0;
int64_t databaseSize = 0;
int64_t databaseTime = 0;
if (!_fileModified(target->cache, &cacheSize, &cacheTime) || !_assetSize(target->db, target->key, &assetSize)) {
return false;
}
if (!_fileModified(target->db->path, &databaseSize, &databaseTime)) {
return false;
}
return (cacheSize == assetSize) && (cacheTime >= databaseTime);
}
// True when the ".." components of a normalised path climb above where it starts: "a/../b" stays // True when the ".." components of a normalised path climb above where it starts: "a/../b" stays
// put, "a/../../b" and "../b" do not. // put, "a/../../b" and "../b" do not.
static bool _climbsAbove(const char *path) { static bool _climbsAbove(const char *path) {
@ -252,7 +228,6 @@ static void _databaseClose(DatabaseT *db) {
free(db->path); free(db->path);
free(db->loose); free(db->loose);
free(db->overlay); free(db->overlay);
free(db->cache);
free(db->gameDir); free(db->gameDir);
free(db); free(db);
} }
@ -564,7 +539,7 @@ static char *_normalise(const char *name) {
} }
// A per-game directory under the data directory: the overlay or the cache. // A game's own directory under the data directory, holding its overlay.
static char *_overlayFor(const char *dataDirBase, const char *dataDir, const char *databasePath, bool isContainer, const char *directory) { static char *_overlayFor(const char *dataDirBase, const char *dataDir, const char *databasePath, bool isContainer, const char *directory) {
char *stem = NULL; char *stem = NULL;
char *result = NULL; char *result = NULL;
@ -681,7 +656,6 @@ static bool _resolve(const char *name, TargetT *target) {
inner = (norm[i] == 0) ? norm + i : norm + i + 1; inner = (norm[i] == 0) ? norm + i : norm + i + 1;
if (target->db->overlay == NULL) { if (target->db->overlay == NULL) {
target->db->overlay = _overlayFor(_dataDirBase, _dataDir, prefix, false, OVERLAY_DIRECTORY); target->db->overlay = _overlayFor(_dataDirBase, _dataDir, prefix, false, OVERLAY_DIRECTORY);
target->db->cache = _overlayFor(_dataDirBase, _dataDir, prefix, false, CACHE_DIRECTORY);
} }
free(prefix); free(prefix);
break; break;
@ -739,10 +713,8 @@ static bool _resolve(const char *name, TargetT *target) {
} }
target->path = utilCreateString("%s%s", target->db->loose, inner); target->path = utilCreateString("%s%s", target->db->loose, inner);
target->overlay = utilCreateString("%s%s", target->db->overlay, target->key); target->overlay = utilCreateString("%s%s", target->db->overlay, target->key);
target->cache = utilCreateString("%s%s", target->db->cache, target->key);
utilFixPathSeparators(&target->path, false); utilFixPathSeparators(&target->path, false);
utilFixPathSeparators(&target->overlay, false); utilFixPathSeparators(&target->overlay, false);
utilFixPathSeparators(&target->cache, false);
free(norm); free(norm);
return true; return true;
@ -753,29 +725,10 @@ static void _targetFree(TargetT *target) {
free(target->path); free(target->path);
free(target->key); free(target->key);
free(target->overlay); free(target->overlay);
free(target->cache);
memset(target, 0, sizeof(*target)); memset(target, 0, sizeof(*target));
} }
static bool _writeFile(const char *path, const uint8_t *data, size_t bytes) {
char *directory = utilGetUpToLastPathComponent(path);
FILE *file = NULL;
bool ok = false;
if (utilMkDirP(directory, 0755)) {
file = fopen(path, "wb");
if (file != NULL) {
ok = (fwrite(data, 1, bytes, file) == bytes);
fclose(file);
}
}
free(directory);
return ok;
}
// The path without its database extension, as a new string; unchanged when it has none. // The path without its database extension, as a new string; unchanged when it has none.
char *vfsDatabaseStem(const char *path) { char *vfsDatabaseStem(const char *path) {
if (_hasDatabaseExtension(path)) { if (_hasDatabaseExtension(path)) {
@ -803,17 +756,18 @@ bool vfsExists(const char *name) {
} }
// A filesystem path Lua's io library can open for the name, or NULL when the name resolves nowhere. // A filesystem path for the name, or NULL when the name resolves nowhere. Writes land in the loose
// Writes land in the loose directory or the overlay, never the database. A packed asset opened for // directory or the overlay, never the database.
// reading is unpacked into the cache, which the lookup never searches, so a later patch of the asset //
// is not shadowed by the copy; a copy that already matches the database is reused. A packed name // There is no path to a packed asset: it is a row in a database and it stays there. Reading one
// nothing holds yields the loose candidate, which fails to open like any missing file. // used to unpack it beside the game and answer with the copy, which is what the engine did before
// anything could read a database row as a stream; everything can now -- vfsStreamOpen and
// vfsOpenIO, and the Lua file object built on the first of them -- so a packed name being read
// yields the loose candidate, which fails to open like any missing file. Ask for a stream instead.
char *vfsFilePath(const char *name, bool forWriting) { char *vfsFilePath(const char *name, bool forWriting) {
TargetT target; TargetT target;
char *path = NULL; char *path = NULL;
char *directory = NULL; char *directory = NULL;
uint8_t *data = NULL;
size_t bytes = 0;
if (!_resolve(name, &target)) { if (!_resolve(name, &target)) {
return NULL; return NULL;
@ -834,23 +788,10 @@ char *vfsFilePath(const char *name, bool forWriting) {
} }
path = target.overlay; path = target.overlay;
target.overlay = NULL; target.overlay = NULL;
} else if (_cacheCurrent(&target)) {
path = target.cache;
target.cache = NULL;
} else {
data = _readAsset(target.db, target.key, &bytes, false);
if (data != NULL) {
if (!_writeFile(target.cache, data, bytes)) {
utilDie("Unable to copy %s to %s.", name, target.cache);
}
free(data);
path = target.cache;
target.cache = NULL;
} else { } else {
path = target.path; path = target.path;
target.path = NULL; target.path = NULL;
} }
}
_targetFree(&target); _targetFree(&target);
return path; return path;
@ -883,9 +824,7 @@ void vfsInit(const char *container, const char *dataDirBase, const char *dataDir
utilDie("%s is not a Singe game database.", container); utilDie("%s is not a Singe game database.", container);
} }
free(_container->overlay); free(_container->overlay);
free(_container->cache);
_container->overlay = _overlayFor(_dataDirBase, _dataDir, container, true, OVERLAY_DIRECTORY); _container->overlay = _overlayFor(_dataDirBase, _dataDir, container, true, OVERLAY_DIRECTORY);
_container->cache = _overlayFor(_dataDirBase, _dataDir, container, true, CACHE_DIRECTORY);
} }
} }
@ -935,6 +874,26 @@ bool vfsIsFilesystem(const char *name) {
// The entries directly under a directory name, from the loose directory, the overlay and the // The entries directly under a directory name, from the loose directory, the overlay and the
// database together, each name once, sorted. Free with vfsListFree. // database together, each name once, sorted. Free with vfsListFree.
// Whether a name lives only inside a database: no loose file and no overlay copy shadows it. That
// is the case that has no filesystem path at all: vfsFilePath answers with a name that is not
// there, and a stream is the only way to read it.
bool vfsIsPacked(const char *name) {
TargetT target;
int64_t size = 0;
bool packed = false;
if (!_resolve(name, &target)) {
return false;
}
packed = (target.db != NULL) && (target.key[0] != 0) &&
!utilFileExists(target.path) && !utilFileExists(target.overlay) &&
_assetSize(target.db, target.key, &size);
_targetFree(&target);
return packed;
}
char **vfsList(const char *name, int32_t *count) { char **vfsList(const char *name, int32_t *count) {
TargetT target; TargetT target;
ListT list; ListT list;

View file

@ -68,6 +68,7 @@ void vfsInit(const char *container, const char *dataDirBase, const char
bool vfsIsDatabase(const char *path); bool vfsIsDatabase(const char *path);
bool vfsIsDirectory(const char *name); bool vfsIsDirectory(const char *name);
bool vfsIsFilesystem(const char *name); bool vfsIsFilesystem(const char *name);
bool vfsIsPacked(const char *name);
char **vfsList(const char *name, int32_t *count); char **vfsList(const char *name, int32_t *count);
void vfsListFree(char **list, int32_t count); void vfsListFree(char **list, int32_t count);
SDL_IOStream *vfsOpenIO(const char *name); SDL_IOStream *vfsOpenIO(const char *name);

BIN
testScripts/Models/DragonModel.glb (Stored with Git LFS)

Binary file not shown.

View file

@ -0,0 +1,52 @@
-- A game description: data, not code. Singe/AuthorCompile.singe turns it into a Singe game.
--
-- Genre one of two. This one uses a single world2d layer and shares nothing with the QTE beside
-- it -- no disc, no branching, no time windows -- which is the point: if the core can express
-- both, it is not secretly a platformer engine or secretly a laserdisc engine.
return {
title = "Author test: platformer",
layers = {
{ kind = "world2d", gravity = 1500 }
},
entities = {
{ id = "ground", x = 360, y = 440, look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } },
{ id = "ledge", x = 520, y = 330, look = { kind = "box", w = 180, h = 20, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } },
{ id = "prize", x = 520, y = 300, look = { kind = "box", w = 18, h = 18, r = 255, g = 200, b = 60 },
behaviours = { { kind = "drift", vx = 0, vy = 0 } } },
{ id = "hero", x = 120, y = 380, look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } },
{ id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } }
},
rules = {
{ note = "Run left",
when = { { "keyHeld", key = "LEFT" } },
act = { { "run", entity = "hero", direction = -1 } } },
{ note = "Run right",
when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } },
{ note = "Jump, but only with ground underfoot",
when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } },
act = { { "jump", entity = "hero" } } },
{ note = "Take the prize once",
when = { { "touching", entity = "hero", other = "prize" }, { "once", tag = "prize" } },
act = { { "addScore", amount = 100 },
{ "show", entity = "prize", visible = false },
{ "setFlag", flag = "won", value = true } } },
{ note = "Fallen off the world: back to the start",
when = { { "below", entity = "hero", y = 520 } },
act = { { "moveTo", entity = "hero", x = 120, y = 380 } } },
{ note = "The readout, every frame",
when = {},
act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } }
}
}

View file

@ -0,0 +1,37 @@
-- Genre two of two: a quick-time event over video. No physics, no character, no gravity -- the
-- only layers are the disc and a flat overlay, and the rules talk about disc frames and windows.
return {
title = "Author test: QTE over video",
layers = {
{ kind = "disc", start = 180 },
{ kind = "overlay" }
},
entities = {
{ id = "prompt", x = 250, y = 60, look = { kind = "text", text = "", r = 255, g = 220, b = 80 } },
{ id = "verdict", x = 250, y = 100, look = { kind = "text", text = "", r = 160, g = 255, b = 180 } },
{ id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } }
},
rules = {
{ note = "The window is open: ask for the button",
when = { { "discBetween", from = 200, to = 260 } },
act = { { "setText", entity = "prompt", text = '"PRESS!"' } } },
{ note = "Answered in time, once",
when = { { "discBetween", from = 200, to = 260 }, { "switchHeld", switch = "SWITCH_BUTTON1" }, { "once", tag = "hit" } },
act = { { "setFlag", flag = "hit", value = true },
{ "addScore", amount = 250 },
{ "setText", entity = "verdict", text = '"HIT"' } } },
{ note = "The window has closed without an answer",
when = { { "discBetween", from = 260, to = 320 }, { "once", tag = "judged" } },
act = { { "setText", entity = "prompt", text = '""' },
{ "lua", code = 'if not AUTHOR_FLAG["hit"] then authorSetText("verdict", "MISS") end' } } },
{ note = "The readout, every frame",
when = {},
act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } }
}
}

View file

@ -1,11 +1,17 @@
-- Prototype: the menu intro. An explosion, the Singe dragon and name flying out of it toward the -- Prototype: the menu intro, through to the menu's own backdrop, as one continuous shot.
-- camera, and the tagline fading in underneath. --
-- An explosion, the Singe dragon and name flying out of it toward the camera, the tagline fading in
-- underneath -- 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 the menu sits on left running. It is one scene
-- from start to finish rather than an intro handing over to something else: there is no cut, and
-- nothing has to be loaded at the join.
-- --
-- The fire is 3D emitters rather than the 2D ones the fireball prototype used, because the logo has -- The fire is 3D emitters rather than the 2D ones the fireball prototype used, because the logo has
-- to come out THROUGH it: 2D particles are drawn with the overlay, on top of the whole scene, so -- to come out THROUGH it: 2D particles are drawn with the overlay, on top of the whole scene, so
-- the logo would have flown out behind its own explosion. -- the logo would have flown out behind its own explosion.
local BLAST_Z = -30.0 -- Where the charge goes off. 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 REST_Z = -8.0 -- Where the logo ends up.
local CAM_Z = 2.0 local CAM_Z = 2.0
local FLY_START = 0.30 -- Seconds: the logo leaves the blast. local FLY_START = 0.30 -- Seconds: the logo leaves the blast.
@ -15,16 +21,46 @@ local TAG_END = 3.00
local SPINS = 2.0 -- Turns the logo makes on the way out. 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_TURN = 7.0 -- Degrees it drifts either side once it has settled.
local IDLE_RATE = 0.9 -- Radians a second of that drift. 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 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
local GRID_SPEED = 9.0
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 = 2.5 -- Where the logo settles, so it sits on the horizon the grid runs to.
-- The dragon carries no animation, but util/dragonModel.py builds it out of named parts -- head, -- 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 -- 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 -- model's origin though, so turning a wing node would swing the wing round the dragon's middle
-- rather than its shoulder. hinge() gives a part the joint it is missing. Coordinates are the -- 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. -- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z.
local WING_JOINT = { 0.30, 4.50, 0.45 } local MOUTH = { 3.55, 5.60, 0.00 } -- Where the flame leaves, in model coordinates.
local NECK_JOINT = { 0.30, 2.70, 0.00 } local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see assets/DragonPivots.json.
local HEAD_JOINT = { 1.00, 5.20, 0.00 }
local MOUTH = { 3.55, 5.60, 0.00 }
local FLAP_MAX = 34.0 -- Degrees a wing rises and falls. 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_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 FLAP_SLOW = 3.4 -- and once it has settled.
@ -41,7 +77,7 @@ local DRAGON_YAW = -32.0 -- The dragon alone is turned toward the camera, s
-- to the camera because it has to be read. -- to the camera because it has to be read.
local TAGLINE = "SINGE Is Not a Game Emulator" local TAGLINE = "SINGE Is Not a Game Emulator"
local SHOT_TIMES = { 0.05, 0.25, 0.55, 0.95, 1.45, 2.00, 2.60, 3.20, 3.50, 3.70, 4.00, 4.60, 5.20 } local SHOT_TIMES = { 0.25, 1.45, 3.00, 3.60, 4.20, 4.70, 5.10, 5.60, 6.10, 6.60, 7.10, 7.60 }
local font = fontLoad("Singe/FreeSansBold.ttf", 30) local font = fontLoad("Singe/FreeSansBold.ttf", 30)
local frames = 0 local frames = 0
@ -58,19 +94,31 @@ sceneSetBloom(1.2, 0.22)
sceneSetTonemap(TONEMAP_ACES) sceneSetTonemap(TONEMAP_ACES)
-- Gives a part the joint the model does not have: a hinge node at the joint, with the part hung -- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from
-- off it and pushed back by the same amount, so the geometry stays where it was and turning the -- assets/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without
-- hinge turns the part about the joint. Returns the hinge to turn. -- anything being rigged here. This used to build them at run time from bounding boxes, which
local function hinge(root, name, joint, parent, parentJoint) -- 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) local part = nodeFind(name, root)
local pivot = nodeNew()
nodeSetParent(pivot, parent or nodeGetParent(part)) if (part ~= nil) and (parent ~= nil) then
nodeSetPosition(pivot, joint[1] - parentJoint[1], joint[2] - parentJoint[2], joint[3] - parentJoint[3]) local px, py, pz = nodeGetPosition(part)
nodeSetParent(part, pivot) local qx, qy, qz = nodeGetPosition(parent)
nodeSetPosition(part, -joint[1], -joint[2], -joint[3])
return pivot -- 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 end
@ -87,7 +135,7 @@ end
local blast = nodeNew() local blast = nodeNew()
nodeSetPosition(blast, 0, 0, BLAST_Z) nodeSetPosition(blast, 0, BLAST_Y, BLAST_Z)
local smoke = emitterNew(blast) local smoke = emitterNew(blast)
emitterSetMax(smoke, 400) emitterSetMax(smoke, 400)
@ -172,17 +220,18 @@ nodeSetScale(logo, 0.5)
nodeSetVisible(logo, false) nodeSetVisible(logo, false)
-- The rig. The head hangs off the neck so that rearing the neck carries the head with it. -- The rig. The head hangs off the neck so that rearing the neck carries the head with it.
local ORIGIN = { 0, 0, 0 } local wingLeft = joint(dragon, "wingL")
local wingLeft = hinge(dragon, "wingL", { WING_JOINT[1], WING_JOINT[2], -WING_JOINT[3] }, nil, ORIGIN) local wingRite = joint(dragon, "wingR")
local wingRite = hinge(dragon, "wingR", WING_JOINT, nil, ORIGIN) local neck = joint(dragon, "neck")
local neck = hinge(dragon, "neck", NECK_JOINT, nil, ORIGIN) -- The head hangs off the neck so that rearing the neck carries the head with it.
local head = hinge(dragon, "head", HEAD_JOINT, neck, NECK_JOINT) local head = joint(dragon, "head", neck)
-- The flame, on a node at the dragon's mouth so it follows the head round. -- The flame, on a node at the dragon's mouth so it follows the head round.
local jet = nodeNew() local jet = nodeNew()
nodeSetParent(jet, head) nodeSetParent(jet, head)
nodeSetPosition(jet, MOUTH[1] - HEAD_JOINT[1], MOUTH[2] - HEAD_JOINT[2], MOUTH[3] - HEAD_JOINT[3]) -- 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. -- The flame's own light, so the dragon is lit by what it is breathing.
local breathLight = lightNew(LIGHT_POINT) local breathLight = lightNew(LIGHT_POINT)
@ -213,8 +262,144 @@ local tagY = math.floor(overlayGetHeight() * 0.72)
spriteUnload(measure) spriteUnload(measure)
-- ===== 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() local camera = nodeNew()
nodeSetPosition(camera, 0, 1.2, CAM_Z) nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
cameraSet(camera) cameraSet(camera)
@ -235,14 +420,35 @@ function onOverlayUpdate()
-- The fire's light dies over the first second and a half. -- The fire's light dies over the first second and a half.
lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5))) 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 if fly > 0 then
local e = easeOut(fly) local e = easeOut(fly)
-- Once it has arrived it keeps turning slowly, so the held frame is not a still picture. -- 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 idle = IDLE_TURN * math.sin((t - FLY_END) * IDLE_RATE) * fly
local yaw = 360 * SPINS * (1 - e) + idle local yaw = 360 * SPINS * (1 - e) + idle
nodeSetVisible(logo, true) -- Out of the blast, then up and away once the grid has taken over. Rising rather than
nodeSetPosition(logo, 0, 0.4 * e, BLAST_Z + (REST_Z - BLAST_Z) * e) -- 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) nodeSetRotation(logo, 0, yaw, 0)
-- Wings: beating hard on the way out, easing to a hover once it has arrived. -- Wings: beating hard on the way out, easing to a hover once it has arrived.
@ -284,8 +490,10 @@ function onOverlayUpdate()
colorBackground(0, 0, 0, 0) colorBackground(0, 0, 0, 0)
overlayClear() overlayClear()
if tag > 0 then local gone = span(t, LIFT_FROM, LIFT_TO - 0.4)
colorForeground(235, 225, 240, math.floor(255 * tag))
if (tag > 0) and (gone < 1) then
colorForeground(235, 225, 240, math.floor(255 * tag * (1 - gone)))
fontPrint(tagX, tagY, TAGLINE) fontPrint(tagX, tagY, TAGLINE)
end end
@ -293,7 +501,7 @@ function onOverlayUpdate()
shotAt = shotAt + 1 shotAt = shotAt + 1
singeScreenshot() singeScreenshot()
end end
if t > 5.6 then if t > 8.2 then
singeQuit() singeQuit()
end end

View file

@ -0,0 +1,9 @@
-- Written for testScripts/packedIo/packedIo.singe; see its header for how to run it.
GAMES = {
{
TITLE = "Packed io",
SCRIPT = "packedIo/packedIo.singe",
RESOLUTION_X = 720,
RESOLUTION_Y = 480
}
}

View file

@ -0,0 +1,3 @@
alpha
beta
gamma

View file

@ -0,0 +1,2 @@
12 34.5 0x10
rest of it

View file

@ -0,0 +1,115 @@
-- Lua's io library against a file that exists only inside a .game. Singe hands those out as its
-- own file object, which reads the database row where it lies rather than unpacking it to a copy,
-- so everything below has to behave exactly as it would on a loose file.
--
-- It has to be run packed, with no loose copy beside the container -- a loose file shadows the
-- packed one and the whole test then proves nothing -- so from a directory holding this folder:
--
-- Singe -P packedIo packedIo.game
-- rm -rf packedIo
-- Singe -w -C 720x480 packedIo.game
--
-- It prints one line: IOTEST pass=N fail=0. Every failure prints what it wanted first.
dofile("Singe/Framework.singe")
local pass = 0
local fail = 0
local function check(what, got, want)
if got == want then
pass = pass + 1
else
fail = fail + 1
debugPrint(string.format("FAIL %s: got %s, wanted %s", what, tostring(got), tostring(want)))
end
end
-- Reading, seeking, and what each format answers at the end of the file.
local f = io.open("packedIo/lines.txt", "rb")
check("io.type", io.type(f), "file")
check("read a", f:read("a"), "alpha\nbeta\ngamma\n")
check("read a at end", f:read("a"), "")
check("seek set", f:seek("set", 0), 0)
check("read l", f:read("l"), "alpha")
check("read L", f:read("L"), "beta\n")
check("read 3", f:read(3), "gam")
check("read l rest", f:read("l"), "ma")
check("read l at end", f:read("l"), nil)
check("seek end", f:seek("end", 0), 17)
check("seek cur back", f:seek("cur", -6), 11)
check("read after seek", f:read("l"), "gamma")
f:close()
check("io.type closed", io.type(f), "closed file")
-- Iterating, both ways round. io.lines closes at the end; f:lines leaves the file alone.
local seen = {}
for line in io.lines("packedIo/lines.txt") do
seen[#seen + 1] = line
end
check("io.lines count", #seen, 3)
check("io.lines last", seen[3], "gamma")
local g = io.open("packedIo/lines.txt")
local count = 0
for _ in g:lines("L") do
count = count + 1
end
check("f:lines count", count, 3)
check("f:lines leaves it open", io.type(g), "file")
g:close()
-- Numbers, and the default input.
local n = io.open("packedIo/numbers.txt")
check("read n", n:read("n"), 12)
check("read n again", n:read("n"), 34.5)
check("read n hex", n:read("n"), 16)
n:close()
io.input("packedIo/numbers.txt")
check("io.read after io.input", io.read("l"), "12 34.5 0x10")
check("io.input() answers", io.type(io.input()), "file")
io.close()
-- A last line with no ending on it.
local t = io.open("packedIo/tail.txt")
check("read l unterminated", t:read("l"), "no newline at the end")
check("read l past it", t:read("l"), nil)
t:close()
-- A packed asset is read only, and a name that is not there answers the way Lua does.
local w = io.open("packedIo/lines.txt")
check("write refused", pcall(function() w:write("x") end), false)
w:close()
local missing, message = io.open("packedIo/nothere.txt")
check("missing is nil", missing, nil)
check("missing has a message", type(message), "string")
-- And a real file in the data directory still behaves as it always did.
local loose = io.open(singeGetDataPath() .. "loose.txt", "w")
loose:write("written\n")
loose:close()
local back = io.open(singeGetDataPath() .. "loose.txt")
check("loose round trip", back:read("l"), "written")
check("loose is a file", io.type(back), "file")
back:close()
debugPrint(string.format("IOTEST pass=%d fail=%d", pass, fail))
singeQuit()
function onOverlayUpdate()
return OVERLAY_NOT_UPDATED
end

View file

@ -0,0 +1 @@
no newline at the end

View file

@ -8,7 +8,7 @@ local frames = 0
local step = 0 local step = 0
local note = "start" local note = "start"
local broken = srtLoad("testScripts/broken.srt") -- No cues in it: false, and nothing loaded. local broken = srtLoad("testScripts/broken.srt") -- No cues in it: false, and nothing loaded.
local good = srtLoad("testScripts/subtitles.srt") -- Four cues over the twelve second disc. local good = srtLoad("testScripts/subtitles.srt") -- Four cues over the first twelve seconds.
-- Each step: a name, what to do when it is reached, and the frame to leave the disc on. -- Each step: a name, what to do when it is reached, and the frame to leave the disc on.
local steps = { local steps = {

95
testScripts/scene52.singe Normal file
View file

@ -0,0 +1,95 @@
-- Authoring tools (PLAN section 58): compiles both sample descriptions and plays the platformer.
--
-- The two descriptions share nothing -- one is physics with a character, the other is disc frames
-- and time windows -- so compiling both here is the test that the core has no genre baked into it.
-- Nothing in this scene knows what a platformer is; it drives keys and looks at the result.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
local built = {}
local out = singeGetDataPath()
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
built.platformer = authorBuild("testScripts/author/platformer.game", out .. "platformer.singe")
built.qte = authorBuild("testScripts/author/qte.game", out .. "qte.singe")
debugPrint("AUTHOR built " .. built.platformer)
debugPrint("AUTHOR built " .. built.qte)
-- Both must at least load as Lua; a compiler that emits something unparseable is the failure this
-- catches earliest, and it costs nothing to check both even though only one is played.
for name, path in pairs(built) do
local chunk, problem = loadfile(path)
if chunk == nil then
debugPrint("AUTHOR FAIL " .. name .. " does not parse: " .. tostring(problem))
else
debugPrint("AUTHOR parsed " .. name)
end
end
-- Now run the platformer, as the engine would if it had been launched directly.
dofile(built.platformer)
local gameUpdate = onOverlayUpdate
local frames = 0
-- Driving by what the game is doing, not by a clock or a frame count.
--
-- Both of those were tried and both are wrong. A frame count is wrong because the authored game
-- moves in seconds. A clock is wrong because physics steps at most MAX_STEPS_PER_FRAME (4, so
-- 66 ms) per frame and deliberately falls behind wall clock on a machine that cannot keep up --
-- which the sanitizer build, with a disc to decode, does. A test that watches the hero instead
-- gives the same answer at any frame rate, and exercises onGround from this side as well.
local JUMP_AT = 340 -- Where the hero leaves the ground to reach the ledge.
local GIVE_UP = 20.0 -- Seconds. Generous: this is a stuck detector, not a schedule.
local held = false
local jumped = false
local won = 0
local shots = 0
function onOverlayUpdate()
local t = authorTime()
local hero = authorEntity("hero")
local hx, hy = authorPosition(hero)
frames = frames + 1
if not held then
authorKeyDown(0, SCANCODE.RIGHT)
held = true
end
-- Jump once, when the hero is under way and has ground beneath it.
if (not jumped) and (hx >= JUMP_AT) and playerIsOnGround(hero.node) then
authorKeyDown(0, SCANCODE.SPACE)
jumped = true
elseif jumped then
authorKeyUp(0, SCANCODE.SPACE)
end
local r = gameUpdate()
-- The engine's own text over the authored game, so a screenshot says what it is looking at.
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored platformer, %.1fs, score %d", t, AUTHOR_SCORE))
-- Four shots at the moments that matter, found by watching rather than by timing: the start,
-- the run, the jump, and the frame after the prize was taken.
if (shots == 0) or (shots == 1 and hx > 250) or (shots == 2 and jumped and not playerIsOnGround(hero.node)) or (shots == 3 and won > 0 and t > won + 0.3) then
shots = shots + 1
singeScreenshot()
end
if AUTHOR_FLAG["won"] and won == 0 then
won = t
end
if (won > 0 and t > won + 0.6) or (t > GIVE_UP) then
debugPrint("AUTHOR RESULT score=" .. AUTHOR_SCORE .. " flagWon=" .. tostring(AUTHOR_FLAG["won"]) .. " after=" .. string.format("%.1fs", t))
singeQuit()
end
return r
end

51
testScripts/scene53.singe Normal file
View file

@ -0,0 +1,51 @@
-- Authoring tools (PLAN section 58), genre two: the QTE description, played over the disc.
--
-- Scene 52 plays the platformer. This one shares no vocabulary with it beyond the three nouns --
-- no physics, no character, no gravity, and every rule is about disc frames -- which is what makes
-- the pair a test of the core rather than of either genre.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/qte.game", singeGetDataPath() .. "qte.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local answered = false
local shots = 0
function onOverlayUpdate()
local disc = discGetFrame()
frames = frames + 1
-- Answer inside the window, the way a player would: hold the button for a few frames.
if (disc >= 215) and (disc < 225) and not answered then
authorSwitchDown(SWITCH_BUTTON1)
answered = true
end
if answered and (disc >= 230) then
authorSwitchUp(SWITCH_BUTTON1)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, "authored QTE, disc frame " .. disc)
-- On the disc's clock, not the frame count: the window is what is being shown.
if (shots == 0 and disc >= 205) or (shots == 1 and disc >= 230) or (shots == 2 and disc >= 280) then
shots = shots + 1
singeScreenshot()
end
if disc >= 310 or frames > 1200 then
debugPrint("AUTHOR QTE RESULT score=" .. AUTHOR_SCORE .. " hit=" .. tostring(AUTHOR_FLAG["hit"]))
singeQuit()
end
return r
end

168
testScripts/scene54.singe Normal file
View file

@ -0,0 +1,168 @@
-- Forge (PLAN section 58): opened on a real description, driven, saved, and the
-- result compiled and played.
--
-- Driven by calling the editor's own functions rather than by a pointer, the way the menu's
-- sanitizer scene drives the menu: what is being tested is that moving something in the editor
-- reaches the description, survives a save, and changes the game that comes out. The pointer path
-- itself was settled by the spike in PLAN section 58 and needs a real X server to exercise.
-- As a library: the tail of Forge.singe that launches it is skipped, because this scene
-- drives it rather than being it.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 16)
local out = singeGetDataPath()
local work = out .. "edited.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
-- Work on a copy; an editor that writes back to the file it was handed is right, but a test that
-- edits the repository's own sample is not.
local source = assert(io.open("testScripts/author/platformer.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("FORGE FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local pointer = { x = 430, y = 300 }
local before = nil
-- Each stage is one thing the editor must be able to do, checked as it happens.
local function step()
if stage == 1 then
-- Pick the prize up off the ledge, the way a press on the canvas would.
local index = forgePick(520, 300)
if index == nil then
debugPrint("FORGE FAIL nothing under the prize's own position")
else
before = FORGE.game.entities[index].x
debugPrint("FORGE picked " .. FORGE.game.entities[index].id .. " at x=" .. before)
forgePress(520, 300)
end
elseif stage == 2 then
-- Drag it left and down, off the ledge and onto the ground.
forgeDrag(300, 300)
pointer.x = 300
elseif stage == 3 then
forgeRelease()
local moved = FORGE.game.entities[forgePick(300, 300)]
debugPrint("FORGE moved to x=" .. moved.x .. " dirty=" .. tostring(FORGE.dirty))
elseif stage == 4 then
forgeSave()
-- Reload from disc and check the move is in the file, not just in memory.
local reloaded = authorLoad(work)
local found = nil
for _, e in ipairs(reloaded.entities) do
if e.id == "prize" then
found = e.x
end
end
debugPrint("FORGE saved and reloaded, prize x=" .. tostring(found) .. " (was " .. tostring(before) .. ")")
if found == before then
debugPrint("FORGE FAIL the move did not survive the save")
end
elseif stage == 5 then
-- And the edit has to reach the game, not just the file.
local built = forgeBuild(out .. "edited.singe")
local text = assert(io.open(built, "r")):read("a")
if text:find('id = "prize", x = 300') then
debugPrint("FORGE the compiled game carries the new position")
else
debugPrint("FORGE FAIL the compiled game does not carry the new position")
end
elseif stage == 6 then
-- The invariant the whole editor rests on: a description that has been through load and
-- save has to compile to the same game as the one that has not. Without it, opening a
-- game in the editor and saving it unchanged could quietly alter it.
local original = authorLoad("testScripts/author/platformer.game")
local trip = out .. "trip.game"
authorSave(original, trip)
local again = authorLoad(trip)
if authorCompile(original) == authorCompile(again) then
debugPrint("FORGE round trip identical")
else
debugPrint("FORGE FAIL round trip changed the game")
end
elseif stage == 7 then
-- The hero lives at x=120, underneath the chrome. A press there must not reach it.
forgeSelect(nil)
forgePress(120, 380)
if FORGE.selected == nil then
debugPrint("FORGE the panel correctly swallows a press beneath it")
else
debugPrint("FORGE FAIL a press under the panel reached the canvas")
end
elseif stage == 8 then
-- Slide the chrome out of the way by pressing its tab, through forgePress, which is
-- the path a real pointer takes. Grabbing the panel through a GUI handler would not be:
-- the spike found a document can go without pointer input entirely, so the tab is drawn on
-- the canvas and this test goes through the same door a player's mouse does.
local tx, ty, tw, th = forgeTab()
forgePress(tx + tw / 2, ty + th / 2)
if FORGE.panelDrag == nil then
debugPrint("FORGE FAIL pressing the tab did not start a panel drag")
end
forgeDrag(440 + tw / 2, 200)
forgeRelease()
pointer.x = 440
debugPrint("FORGE panel moved to x=" .. FORGE.panelX)
elseif stage == 9 then
-- And now the same press reaches the hero, which is the whole point of a movable panel.
forgePress(120, 380)
if FORGE.selected ~= nil and FORGE.game.entities[FORGE.selected].id == "hero" then
debugPrint("FORGE the hero is reachable once the panel has moved")
else
debugPrint("FORGE FAIL the hero is still unreachable")
end
forgeRelease()
debugPrint("FORGE RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 12 == 0 and stage < 10 then
stage = stage + 1
step()
end
forgeDraw(pointer.x, pointer.y)
colorForeground(255, 255, 255, 255)
fontPrint(200, 455, "Forge, stage " .. stage)
if frames == 10 or frames == 54 or frames == 88 or frames == 118 then
singeScreenshot()
end
if frames > 130 then
singeQuit()
end
return OVERLAY_UPDATED
end

176
testScripts/scene55.singe Normal file
View file

@ -0,0 +1,176 @@
-- The event sheet (PLAN section 58): rules edited, not just entities moved.
--
-- Driven by keys, which is how the editor is meant to be used and how the bundled menu's document
-- has always been driven. What is asserted is that an edit made through the rule editor reaches
-- the compiled game -- adding a condition has to change what the game actually does.
-- As a library: the tail of Forge.singe that launches it is skipped, because this scene
-- drives it rather than being it.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "rules.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/platformer.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("RULES FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local before = ""
-- The engine hands onKeyPressed two integers, so the test does too. Passing the SCANCODE table
-- instead made every comparison in forgeKey false and the test still passed, which is worse than
-- failing: the keyboard did nothing for a real user and nothing said so.
local function key(code, character)
forgeKey(character or 0, code.value)
end
local function compiled()
FORGE.game.source = work
return authorCompile(FORGE.game)
end
local function step()
if stage == 1 then
key(SCANCODE.TAB)
debugPrint("RULES mode is now " .. FORGE.mode .. ", rules " .. #FORGE.game.rules)
elseif stage == 2 then
-- Walk to the second rule the way the arrow keys do.
key(SCANCODE.DOWN)
key(SCANCODE.DOWN)
key(SCANCODE.DOWN)
debugPrint("RULES at rule " .. FORGE.rule .. " part " .. FORGE.part)
elseif stage == 3 then
before = compiled()
-- A new rule, then a condition and an action on it, all from the manifest.
forgeRuleNew("jump with the up arrow too")
forgeRuleAdd("when", "keyHeld")
forgePartSet("key", "UP")
forgeRuleAdd("when", "onGround")
forgePartSet("entity", "hero")
forgeRuleAdd("act", "jump")
forgePartSet("entity", "hero")
debugPrint("RULES built a rule with " .. #forgeParts(FORGE.game.rules[FORGE.rule]) .. " parts")
elseif stage == 4 then
local after = compiled()
if after == before then
debugPrint("RULES FAIL the new rule did not reach the compiled game")
elseif after:find('authorKeyHeld%(SCANCODE%.UP%)') and after:find('authorOnGround%("hero"%)') and after:find('authorJump%("hero"%)') then
debugPrint("RULES the compiled game carries the new rule")
else
debugPrint("RULES FAIL the compiled game is missing part of the new rule")
end
elseif stage == 5 then
-- And deleting a part has to take it back out again.
FORGE.part = 1
forgePartDelete()
local after = compiled()
if after:find('authorKeyHeld%(SCANCODE%.UP%)') then
debugPrint("RULES FAIL the deleted condition is still compiled")
else
debugPrint("RULES the deleted condition is gone from the compiled game")
end
elseif stage == 6 then
-- Type a value the way a person does: ENTER opens the first parameter, characters go in,
-- ENTER commits. Rule 1 is "Run left", whose only condition is keyHeld with key = LEFT.
FORGE.rule = 1
FORGE.part = 1
key(SCANCODE.RETURN)
for c in string.gmatch("RIGHT", ".") do
key(SCANCODE.A, string.byte(c))
end
key(SCANCODE.RETURN)
local typed = FORGE.game.rules[1].when[1]
if typed.key == "RIGHT" then
debugPrint("RULES typed a value in: key = " .. tostring(typed.key))
else
debugPrint("RULES FAIL typing did not reach the rule: key = " .. tostring(typed.key))
end
elseif stage == 7 then
-- A number has to come back a number: "100" and 100 compile to different source, so a
-- retyped value that turned into a string would stop the description round-tripping.
-- Rule 4 is "Take the prize once"; its third part is addScore, whose value is amount.
FORGE.rule = 4
FORGE.part = 3
key(SCANCODE.RETURN)
for c in string.gmatch("250", ".") do
key(SCANCODE.A, string.byte(c))
end
key(SCANCODE.RETURN)
local amount = FORGE.game.rules[4].act[1].amount
if amount == 250 and type(amount) == "number" then
debugPrint("RULES a typed number is a number: " .. tostring(amount))
else
debugPrint("RULES FAIL amount is " .. type(amount) .. " " .. tostring(amount))
end
-- And ESC has to put back what was there.
FORGE.part = 1
key(SCANCODE.RETURN)
key(SCANCODE.A, string.byte("Z"))
key(SCANCODE.ESCAPE)
if FORGE.game.rules[4].when[1].entity == "hero" then
debugPrint("RULES escape put the value back")
else
debugPrint("RULES FAIL escape left " .. tostring(FORGE.game.rules[4].when[1].entity))
end
elseif stage == 8 then
key(SCANCODE.S)
local reloaded = authorLoad(work)
local last = reloaded.rules[#reloaded.rules]
debugPrint("RULES saved; last rule is now '" .. tostring(last.note) .. "' with " .. #(last.when or {}) .. " conditions")
debugPrint("RULES RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 9 then
stage = stage + 1
step()
end
forgeDraw(nil, nil)
colorForeground(255, 255, 255, 255)
fontPrint(210, 455, "event sheet, stage " .. stage)
if frames == 16 or frames == 44 or frames == 62 or frames == 96 then
singeScreenshot()
end
if frames > 150 then
singeQuit()
end
return OVERLAY_UPDATED
end

91
testScripts/scene56.singe Normal file
View file

@ -0,0 +1,91 @@
-- A Forge release is standalone (PLAN section 58).
--
-- Building it is half the test; the half that matters is that the result runs somewhere Forge has
-- never been. The runtime a compiled game loads is Singe/Author.singe, which whatever Singe the
-- player has will have extracted -- but a game should not change behaviour because the engine under
-- it moved on, so a release carries its own copy and loads that instead.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "release.game"
local dest = out .. "Released"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/platformer.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("RELEASE FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local function step()
if stage == 1 then
local folder = forgeExport(dest, "Platformer")
debugPrint("RELEASE built into " .. tostring(folder))
elseif stage == 2 then
-- Everything a standalone game needs has to be there.
for _, name in ipairs({ "Platformer.singe", "Author.singe", "games.dat", "Platformer.game" }) do
local f = io.open(dest .. "/" .. name, "r")
if f == nil then
debugPrint("RELEASE FAIL missing " .. name)
else
f:close()
end
end
debugPrint("RELEASE all four files present")
elseif stage == 3 then
-- And it must load its own runtime, not the engine's.
local text = assert(io.open(dest .. "/Platformer.singe", "r")):read("a")
if text:find('dofile%(here %.%. "Author%.singe"%)') and text:find('debug%.getinfo') then
debugPrint("RELEASE the game finds its own directory and loads the runtime there")
elseif text:find('Singe/Author%.singe') then
debugPrint("RELEASE FAIL the game still loads the engine's runtime, which no longer ships")
else
debugPrint("RELEASE FAIL the game does not load a runtime at all")
end
elseif stage == 4 then
-- Whether it truly stands alone cannot be answered from in here: DIR is the directory of
-- the script the engine was *launched* with, so a game reached by dofile would look for the
-- runtime beside this scene instead of beside itself. A released game is the main script,
-- which is a different run of the engine. harness/verify.sh launches it with
-- Singe/Author.singe moved aside and checks it still runs.
debugPrint("RELEASE RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 5 then
stage = stage + 1
step()
end
if frames == 20 or frames == 70 then
singeScreenshot()
end
if frames > 90 then
singeQuit()
end
return OVERLAY_UPDATED
end

521
util/makeMenuSound.py Normal file
View file

@ -0,0 +1,521 @@
# The menu backdrop's sound: the sting the intro runs to, with a bed underneath it that fades out
# as the menu takes the screen. It is written from nothing -- there is no sample library here, and
# an engine shipping media nobody can account for is exactly what the drawn backdrop was made to
# stop -- so every noise below is an oscillator, a burst of noise, or an envelope over one of the
# two. numpy does the arithmetic, as it does for the model tools; the reverb is a convolution and
# wants the FFT.
#
# One file comes out of it, menuIntro.flac, exactly as long as the backdrop's intro. Nothing loops:
# music under a menu that is waiting for someone to choose a game wears out its welcome in about
# fifteen seconds, so the bed fades away once the menu has the screen and what is left is quiet.
# That is also why the recording's looping section carries silence.
#
# No length is written down here. util/renderMenuVideo.py asks the backdrop where its intro ends
# and passes it in, so the sound cannot drift out of step with the picture.
#
# Usage: python3 util/renderMenuVideo.py (which calls makeSound below)
# python3 util/makeMenuSound.py --intro 9.90 --out assets
import argparse
import math
import os
import subprocess
import wave
import numpy as np
RATE = 44100
SEED = 20260913 # Two runs write the same file, so a re-render can be compared with the
# last one and the difference is the picture, not the dice.
TAIL = 0.006 # Seconds an envelope is given to reach silence before it is cut off.
BED_PEAK = 0.34 # How loud the bed is. Everything else is fitted around it, because it
# is the one thing playing under the whole intro.
PEAK = 0.97 # Where the limiter tops out.
KNEE = 0.80 # and where it starts to bend. Below this nothing is touched at all.
# High, so the charge's transient comes through as it was made rather
# than rounded off: the limiter is here to catch the last of it, not to
# flatten the loudest moment in the file into the same shape as the rest.
# The intro's marks, in seconds, from assets/Backdrop.singe. They are here rather than read out of
# it because the Lua is the picture's copy and this is the sound's; what has to agree between the
# two -- where the whole thing ends -- is passed in instead of guessed at.
FLY_START = 0.30
FLY_END = 1.90
FLAP_FAST = 13.0
FLAP_SLOW = 3.4
BREATH_START = 3.46
BREATH_END = 4.35
GRID_UP = 4.30
GRID_LIT = 5.60
LIFT_FROM = 5.90
LIFT_TO = 7.30 # The menu has the screen from here, and the music starts leaving.
# The tempo comes out of the picture: four bars land exactly on LIFT_TO, so the last chord of the
# phrase is the one the menu arrives on.
BEATS = 4
BARS_TO_MENU = 4
ROOT = 220.0 # A3. The grid is magenta and the sun is orange; the key is A minor.
HARMONICS = 12 # Partials in the sawtooth. Twelve keeps the top of the arpeggio, the
# highest note here, inside half the sample rate, so nothing aliases.
# The chord for each bar in turn, as semitones from the root, and the bass note beneath it.
CHORDS = (((0, 3, 7, 12), -12), ((-4, 0, 5, 8), -16), ((-5, 0, 3, 7), -17), ((-4, 0, 5, 8), -16))
ARPEGGIO = (0, 7, 12, 15, 12, 7, 12, 3) # Sixteenths, the same shape over every chord.
# The room the sting is heard in. A blast with nothing around it is a click: what makes it big is
# the second and a half of room that answers it.
ROOM_SECONDS = 2.6
ROOM_DECAY = 0.85 # Seconds the room falls by a factor of e.
ROOM_DARK = 1500 # It loses its top as it goes, the way a room does, and a long way off
# only the bottom of a blast is left at all.
ROOM_PREDELAY = 0.014 # The dry sound is heard on its own first, or it arrives already blurred.
CHARGE_DRIVE = 0.9 # How hard the blast and the flame are saturated. Gently: it is for
FLAME_DRIVE = 1.3 # grit, and anything more flattens the very dynamics that are the punch.
ROOM_SLAPS = ((0.061, 0.30), (0.113, 0.20), (0.187, 0.12)) # Distinct returns off whatever is out
# there. A blast in the open is heard once and then answered; a smooth
# tail on its own is a plate reverb, which is a studio and not a place.
def seconds(count):
return np.arange(count) / RATE
def note(semitones):
return ROOT * (2.0 ** (semitones / 12.0))
# Convolution through the FFT. Every fixed filter here is one of these -- a one pole low pass is
# just a decaying exponential to convolve with -- which keeps the whole file to array arithmetic
# instead of a per sample loop, and makes the reverb affordable at all.
def convolve(signal, kernel):
size = 1
while size < len(signal) + len(kernel):
size *= 2
out = np.fft.irfft(np.fft.rfft(signal, size) * np.fft.rfft(kernel, size), size)
return out[:len(signal)]
# A one pole low pass, as the exponential it is. The kernel is cut off where it has fallen below
# a hundred thousandth, which is inaudible and keeps the transform small.
def lowpass(signal, cutoff):
pole = math.exp(-2.0 * math.pi * cutoff / RATE)
length = min(int(math.log(1e-5) / math.log(pole)) + 1, len(signal))
kernel = (1.0 - pole) * pole ** np.arange(length)
return convolve(signal, kernel)
def highpass(signal, cutoff):
return signal - lowpass(signal, cutoff)
def bandpass(signal, low, high):
return highpass(lowpass(signal, high), low)
# A low pass whose cutoff moves, which no single convolution can do: a blast is a bright crack
# that turns into a rumble, and that turn is the filter closing. One pole, one multiply a sample,
# written out because the recursion cannot be vectorised.
def sweepLowpass(signal, cutoffs):
poles = np.exp(-2.0 * math.pi * np.clip(cutoffs, 10.0, RATE / 2.2) / RATE)
out = np.empty(len(signal))
last = 0.0
for i, pole in enumerate(poles):
last = signal[i] * (1.0 - pole) + last * pole
out[i] = last
return out
# A two pole state variable filter with a moving cutoff, which rings at the cutoff as the resonance
# goes up. The ring is the whole point of a riser: a swept resonance is what makes noise sound
# like it is climbing towards something.
def resonant(signal, cutoffs, resonance):
out = np.empty(len(signal))
steps = 2.0 * np.sin(np.pi * np.clip(cutoffs, 10.0, RATE / 2.2) / RATE)
damping = 1.0 / resonance
low = 0.0
band = 0.0
for i, step in enumerate(steps):
high = signal[i] - low - band * damping
band += step * high
low += step * band
out[i] = low
return out
# A slow random wander between 0 and 1: noise with everything above a few hertz taken off it.
# This is what the fire and the blast are modulated by. The first attempt used a pair of sine
# waves instead and that is precisely what made them sound made up: nothing in a fire repeats.
def flutter(count, rate, rng):
shape = lowpass(noise(count, rng), rate)
shape -= shape.min()
worst = shape.max()
return shape / worst if worst > 0.0 else shape
# Sparse pops: rubble coming down, or the spitting inside a flame. An impulse every so often at
# a random moment and a random size, each one smeared into a short burst of its own. Fire and
# debris are made of these, and a filtered hiss without them is a hiss.
def crackle(count, perSecond, rng, low, high, length=0.05):
train = np.zeros(count)
at = rng.integers(0, count, size=max(int(perSecond * count / RATE), 1))
np.add.at(train, at, rng.uniform(0.2, 1.0, size=len(at)) * rng.choice((-1.0, 1.0), size=len(at)))
burst = noise(int(length * RATE), rng) * np.exp(-seconds(int(length * RATE)) / (length / 4.0))
return bandpass(convolve(train, burst), low, high)
# A band pass whose band moves, for a flame whose resonance wanders. Two sweeping one poles: the
# lower one taken off the upper leaves what is between them.
def sweepBandpass(signal, low, high):
return sweepLowpass(signal, high) - sweepLowpass(signal, low)
# An envelope follower: fast to rise, slow to fall, which is how a compressor hears a sound and
# how the blast gets to push everything else out of its way.
def follow(signal, attack, release):
out = np.empty(len(signal))
rise = math.exp(-1.0 / (attack * RATE))
fall = math.exp(-1.0 / (release * RATE))
last = 0.0
for i, value in enumerate(np.abs(signal)):
pole = rise if value > last else fall
last = value * (1.0 - pole) + last * pole
out[i] = last
return out
# Everything that is not the blast, pushed out of the blast's way and let back in. Punch is
# contrast: a loud sound with nothing standing next to it is heard as louder than the same sound
# with the rest of the mix holding its level underneath.
def duck(signal, trigger, amount, attack, release):
envelope = follow(trigger, attack, release)
worst = envelope.max()
return signal * (1.0 - amount * envelope / worst) if worst > 0.0 else signal
# The room, as an impulse to convolve with: noise that dies away, darkening as it goes, with the
# first few milliseconds left empty so the dry sound arrives before its reflections do.
def room(rng):
count = int(ROOM_SECONDS * RATE)
impulse = rng.standard_normal(count) * np.exp(-seconds(count) / ROOM_DECAY)
for at, level in ROOM_SLAPS:
impulse[int(at * RATE)] += level
impulse = lowpass(impulse, ROOM_DARK)
impulse[:int(ROOM_PREDELAY * RATE)] = 0.0
return impulse / math.sqrt(float(np.sum(impulse * impulse)))
# An oscillator whose frequency is given a sample at a time.
def sweep(frequencies):
return np.sin(2.0 * math.pi * np.cumsum(frequencies) / RATE)
# A band limited sawtooth, built one harmonic at a time.
def saw(t, frequency):
out = np.zeros(len(t))
for h in range(1, HARMONICS + 1):
if frequency * h < RATE / 2.0:
out += np.sin(2.0 * math.pi * frequency * h * t) / h
return out * 2.0 / math.pi
def noise(count, rng):
return rng.standard_normal(count)
# An envelope that rises in attack seconds and falls away over decay, fast to start and slow to
# finish: a struck sound rather than a triangle. An exponential never actually reaches zero, so
# the last few milliseconds are taken down by hand; cutting one off where it still had a tenth of
# its level left is a click, and the bed has hundreds of these in it.
def hit(attack, decay, length):
count = int(length * RATE)
t = seconds(count)
rise = np.clip(t / max(attack, 1e-6), 0.0, 1.0)
fall = np.exp(-np.clip(t - attack, 0.0, None) / decay)
close = np.clip((count - 1 - np.arange(count)) / (TAIL * RATE), 0.0, 1.0)
return rise * fall * close
# A window that comes up, holds, and goes down again: for the parts of the intro that have a
# length of their own rather than a decay.
def swell(length, rise, fall):
count = int(length * RATE)
up = np.clip(np.arange(count) / (rise * RATE), 0.0, 1.0)
down = np.clip((count - 1 - np.arange(count)) / (fall * RATE), 0.0, 1.0)
return np.minimum(up, down)
# Lays a signal into the take at a time, as long as whatever is shorter.
def lay(into, signal, start, level=1.0):
first = max(int(start * RATE), 0)
length = min(len(signal), len(into) - first)
if length > 0:
into[first:first + length] += signal[:length] * level
# Where the dragon's wings reach the bottom of a beat, worked out the way assets/Backdrop.singe
# works it out: the flap is sin(t * rate) with the rate easing from fast to slow as the logo flies
# out of the blast, so the gusts land on the animation rather than near it.
def flapTimes(until):
t = np.arange(0.0, until, 1.0 / 240.0)
span = np.clip((t - FLY_START) / (FLY_END - FLY_START), 0.0, 1.0)
value = np.sin(t * (FLAP_FAST + (FLAP_SLOW - FLAP_FAST) * (1.0 - (1.0 - span) ** 3)))
falling = (value[:-1] > 0.0) & (value[1:] <= 0.0) & (t[:-1] > FLY_START)
return t[:-1][falling]
# The charge. This is the one sound in the intro that has to be felt rather than heard, and the
# first two attempts were not. The first peaked the meter with a sine at thirty hertz, which moves
# no air at all on the speaker a cabinet or a laptop actually has. The second had the weight and
# still sounded made up, for the reason every synthesised blast sounds made up: smooth envelopes
# over steady noise. Nothing about an explosion is smooth or steady. What is here now is five
# layers with the detail put back -- a crack, a slam through the middle of the bass, a drop that
# falls away twice over, a body whose level flickers at random as it closes, and rubble coming
# down afterwards -- and then the sum of them driven into a soft clipper, which is what a blast
# does to whatever is recording it and what turns five layers into one sound rather than five.
def chargeTake(length, rng):
count = int(length * RATE)
t = seconds(count)
out = np.zeros(count)
# The crack: a fraction of a millisecond to full scale, and gone in twenty. This is the part
# that is heard as the thing having happened suddenly.
out += highpass(noise(count, rng), 1600) * hit(0.0002, 0.02, length) * 1.8
# The slam: two hundred to nine hundred hertz, gone in a twentieth of a second. It is this
# band, not the sub, that a small speaker turns into "loud".
out += bandpass(noise(count, rng), 200, 900) * hit(0.0008, 0.05, length) * 1.8
# The drop. Two decays rather than one -- most of it goes in a tenth of a second and the rest
# takes a second to follow -- because a single exponential is heard as a synthesiser's kick.
# Saturated, so the harmonics carry the pitch that a small speaker cannot make.
drop = sweep(33.0 + 150.0 * np.exp(-t / 0.16)) * (0.72 * np.exp(-t / 0.085) + 0.28 * np.exp(-t / 0.85))
out += np.tanh(drop * 3.4) / math.tanh(3.4) * 1.0
# The body: noise with its top closing, flickering at random as it goes. The flicker is the
# difference between a blast and a cymbal.
body = sweepLowpass(noise(count, rng), 4200.0 * np.exp(-t / 0.26) + 80.0) * hit(0.001, 0.5, length)
out += body * (0.35 + 0.65 * flutter(count, 26, rng)) * 1.3
# Rubble.
out += crackle(count, 110, rng, 160, 3000) * hit(0.12, 1.5, length) * 0.8
# A little grit, and only a little. Driving the sum of the layers hard is how the second
# attempt lost its punch altogether: everything from the crack to the last of the rubble came
# out at the same level, which is a wall and not a blast. The sum is brought to full scale
# first so the amount of drive is the amount asked for rather than however many layers happen
# to be sounding at that instant.
return drive(out, CHARGE_DRIVE)
# The dragon's fire. Filtered noise with a tremolo on it is the textbook fake flame, and that is
# what this was: a band of noise wobbled by two sine waves. A flame is three things happening at
# once and none of them is periodic -- air catching alight, a roar whose loudness wanders at random,
# and the spitting inside it -- and the resonance of the column moves the whole time, which is why
# the band pass here is swept by a random walk rather than fixed.
def flameTake(length, rng):
count = int(length * RATE)
t = seconds(count)
out = np.zeros(count)
# The air catching: a bright burst that closes almost at once.
out += sweepLowpass(noise(count, rng), 6000.0 * np.exp(-t / 0.07) + 320.0) * hit(0.005, 0.11, length) * 1.1
# The roar, and the column's own resonance wandering over it.
loud = 0.30 + 0.70 * flutter(count, 9, rng)
out += lowpass(noise(count, rng), 420) * loud * 1.5
centre = 520.0 * (1.0 + 1.4 * flutter(count, 5, rng))
out += sweepBandpass(noise(count, rng), centre * 0.55, centre * 2.2) * loud * 1.2
# The spitting, and the hiss of the jet. Both go with the roar rather than running underneath
# it at a level of their own: what a flame does is surge, and everything in it surges together.
out += crackle(count, 190, rng, 700, 7000) * loud * 0.5
out += highpass(noise(count, rng), 4500) * loud * 0.35
return drive(out, FLAME_DRIVE)
# The sting: the charge and everything that comes out of it, in a room. The bed is not in here;
# it goes underneath afterwards.
def stingTake(length, rng):
count = int(length * RATE)
out = np.zeros(count)
charge = np.zeros(count)
lay(charge, chargeTake(min(3.0, length), rng), 0.0, 1.0)
# The logo coming out of the blast: a rush that rises as it arrives and stops when it stops.
lay(out, bandpass(noise(int(1.65 * RATE), rng), 300, 4000) * swell(1.65, 0.5, 0.45), FLY_START, 0.26)
# Wings.
gust = bandpass(noise(int(0.35 * RATE), rng), 180, 2200) * hit(0.03, 0.12, 0.35)
for at in flapTimes(length):
lay(out, gust, at, 0.16)
# The flame.
burn = BREATH_END - BREATH_START
lay(out, flameTake(burn, rng) * swell(burn, 0.05, 0.4), BREATH_START, 0.5)
# The grid coming up out of the dark, twice over: a note that rises with it, and noise through
# a resonance climbing the same way, which is the sound of something being switched on.
climb = GRID_LIT - GRID_UP
count = int(climb * 1.1 * RATE)
ramp = np.clip(seconds(count) / climb, 0.0, 1.0)
lay(out, sweep(note(-12) * (1.0 + ramp * 3.0)) * swell(climb * 1.1, 0.9, 0.5), GRID_UP, 0.11)
lay(out, resonant(noise(count, rng), 180.0 * (1.0 + ramp * 32.0), 9.0) * swell(climb * 1.1, 1.0, 0.35), GRID_UP, 0.11)
# The logo leaving.
lay(out, bandpass(noise(int((LIFT_TO - LIFT_FROM) * RATE), rng), 2000, 11000) * swell(LIFT_TO - LIFT_FROM, 0.5, 0.6), LIFT_FROM, 0.08)
# Everything else gets out of the charge's way and comes back over the next quarter second.
out = duck(out, charge, 0.8, 0.003, 0.22) + charge
space = room(rng)
return out + convolve(out, space) * 0.42
# The bed: a pad, a bass and an arpeggio, bar after bar for as long as it is wanted. It is played
# straight through rather than made once and repeated, so the echo and the pad carry across a bar
# line the way they would if somebody played it.
def bedTake(length, beat, rng):
count = int(length * RATE)
out = np.zeros(count)
sixteenth = beat / 4.0
held = swell(BEATS * beat + 0.25, 0.35, 0.5)
plucked = hit(0.004, sixteenth * 1.6, sixteenth * 4)
struck = hit(0.01, beat * 0.7, beat * 2)
heldT = seconds(len(held))
pluckT = seconds(len(plucked))
struckT = seconds(len(struck))
for bar in range(int(math.ceil(length / (BEATS * beat)))):
chord, bass = CHORDS[bar % len(CHORDS)]
barAt = bar * BEATS * beat
# The pad: the chord held for the whole bar, soft, slow to arrive, and detuned against
# itself so it moves rather than sits there.
for semitone in chord:
lay(out, (saw(heldT, note(semitone)) + saw(heldT, note(semitone) * 1.004)) * held, barAt, 0.045)
# The bass: one note a bar, and another on the last beat to lean into the next one.
for at, level in ((barAt, 0.5), (barAt + 3 * beat, 0.34)):
lay(out, np.sin(2.0 * math.pi * note(bass) * struckT) * struck, at, level)
lay(out, saw(struckT, note(bass) * 2) * struck, at, level * 0.25)
# The arpeggio: sixteenths, plucked, riding on top.
for step in range(BEATS * 4):
semitone = chord[0] + ARPEGGIO[step % len(ARPEGGIO)]
lay(out, saw(pluckT, note(semitone) * 2) * plucked, barAt + step * sixteenth, 0.06)
# A quarter note echo, and a breath of air under all of it.
echo = int(round(beat * RATE))
for _ in range(3):
out[echo:] += out[:-echo] * 0.32
out += lowpass(noise(count, rng), 900) * 0.012
return out
# Saturation, over a signal brought to full scale first. tanh on its own is only a soft clipper --
# how much it does depends entirely on how loud what goes into it happens to be -- and that is not
# a control, it is an accident.
def drive(signal, amount):
signal = normalise(signal, 1.0)
return np.tanh(signal * amount) / math.tanh(amount)
def normalise(signal, peak):
worst = float(np.max(np.abs(signal)))
return signal * (peak / worst) if worst > 0.0 else signal
# The music leaves as the menu arrives: full level until LIFT_TO, then away to nothing by the end
# of the file. A cosine rather than a straight line, because a straight fade is heard as a shove
# at the start and a long nothing at the end.
def fade(signal, at):
first = int(at * RATE)
away = (1.0 + np.cos(math.pi * np.arange(len(signal) - first) / (len(signal) - first))) / 2.0
signal[first:] *= away
return signal
# A soft limiter, so the loudest moment can be loud without deciding how loud everything else is.
# Below the knee nothing is touched; above it the curve bends over towards the ceiling, which is
# what lets the charge sit at the top of the scale without the rest of the intro being scaled down
# to make room for its peak.
def limit(signal):
over = np.abs(signal) > KNEE
room = PEAK - KNEE
signal = signal.copy()
signal[over] = np.sign(signal[over]) * (KNEE + room * np.tanh((np.abs(signal[over]) - KNEE) / room))
return signal
# A little width: the same sound a few samples apart is enough for a menu, and it stays mono
# compatible, which matters on a cabinet with one speaker.
def stereo(signal, shift=48):
late = np.concatenate((np.zeros(shift), signal[:-shift]))
return np.stack((signal * 0.92 + late * 0.08, signal * 0.92 - late * 0.08), axis=1)
# FLAC rather than a compressed format: it is what the video's audio track is muxed from, and a
# lossy encoder pads both ends of what it encodes, which the engine's seeks would find.
def writeFlac(path, frames):
temp = path + ".wav"
data = np.clip(frames, -1.0, 1.0)
with wave.open(temp, "wb") as out:
out.setnchannels(2)
out.setsampwidth(2)
out.setframerate(RATE)
out.writeframes((data * 32767.0).astype("<i2").tobytes())
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", temp, "-c:a", "flac", path], check=True)
os.unlink(temp)
# Writes the file and answers its path. Called by util/renderMenuVideo.py with the length the
# backdrop itself reported, so nothing here has to know how long the intro is.
def makeSound(introSeconds, folder):
rng = np.random.default_rng(SEED)
path = os.path.join(folder, "menuIntro.flac")
beat = LIFT_TO / (BARS_TO_MENU * BEATS)
bed = normalise(bedTake(introSeconds, beat, rng), BED_PEAK)
# Only a little above full scale: enough that the loudest moment meets the limiter and nothing
# else does. At 1.5 the limiter was holding the whole first second at the same level, which
# took the shape out of the blast as surely as over-driving it had.
sting = normalise(stingTake(introSeconds, rng), 1.15)
# The bed comes up under the flame and is established by the time the grid is.
rising = np.clip((seconds(len(bed)) - BREATH_START) / (GRID_UP - BREATH_START), 0.0, 1.0)
writeFlac(path, stereo(fade(limit(sting + bed * rising), LIFT_TO)))
return path
def main():
parser = argparse.ArgumentParser(description="write the menu backdrop's sound")
parser.add_argument("--intro", type=float, required=True, help="seconds the backdrop's intro lasts")
parser.add_argument("--out", default=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets"))
args = parser.parse_args()
print(makeSound(args.intro, args.out))
if __name__ == "__main__":
main()

View file

@ -2,7 +2,13 @@
# format Singe loads. Each OBJ object becomes a node with one mesh primitive per material; faces are # format Singe loads. Each OBJ object becomes a node with one mesh primitive per material; faces are
# fan-triangulated and keep their flat normals. Materials map Kd/d to the base colour and alpha, # fan-triangulated and keep their flat normals. Materials map Kd/d to the base colour and alpha,
# Pm/Pr (or a guess from Ks/Ns) to metallic and roughness; translucent ones render blended, two-sided. # Pm/Pr (or a guess from Ks/Ns) to metallic and roughness; translucent ones render blended, two-sided.
# Usage: python3 util/objToGlb.py model.obj model.glb [--root Name] #
# --pivots takes a JSON file naming, per object, where that object's own origin should sit: three
# entries picked from "min", "mid" and "max" of its bounding box, or plain numbers. The vertices
# are moved by that much and the node carries it back as a translation, so the model looks exactly
# the same but each part now turns about a joint instead of about the model's origin. Without it
# every node sits at 0,0,0 and rotating a wing swings it around the middle of the animal.
# Usage: python3 util/objToGlb.py model.obj model.glb [--root Name] [--pivots joints.json]
import argparse import argparse
import json import json
import os import os
@ -13,6 +19,7 @@ parser = argparse.ArgumentParser()
parser.add_argument('obj') parser.add_argument('obj')
parser.add_argument('out') parser.add_argument('out')
parser.add_argument('--root', default=None, help='name of the root node (default: the file name)') parser.add_argument('--root', default=None, help='name of the root node (default: the file name)')
parser.add_argument('--pivots', default=None, help='JSON naming each object\'s own origin, so its node can turn about a joint')
args = parser.parse_args() args = parser.parse_args()
@ -117,13 +124,53 @@ for name in materialNames:
entry['doubleSided'] = True entry['doubleSided'] = True
glMaterials.append(entry) glMaterials.append(entry)
# Where each object's origin should sit, resolved against its own bounding box.
pivots = {}
if args.pivots:
with open(args.pivots) as handle:
pivots = {k: v for k, v in json.load(handle).items() if not k.startswith('_')}
unknown = [name for name in pivots if name not in objects]
if unknown:
# A pivot naming a part that is not in the model is a rename that was not carried across,
# and silently ignoring it would leave that part turning about the model's origin.
raise SystemExit('%s: no such object(s) in %s: %s' % (args.pivots, args.obj, ', '.join(sorted(unknown))))
def pivotFor(name, verts):
rule = pivots.get(name)
if rule is None:
return None
low = verts.min(axis=0)
high = verts.max(axis=0)
out = []
for axis in range(3):
want = rule[axis]
if isinstance(want, (int, float)):
out.append(float(want))
elif want == 'min':
out.append(float(low[axis]))
elif want == 'max':
out.append(float(high[axis]))
elif want == 'mid':
out.append(float((low[axis] + high[axis]) / 2.0))
else:
raise SystemExit('%s: %s has an unknown axis rule %r' % (args.pivots, name, want))
return out
meshes = [] meshes = []
nodes = [] nodes = []
for name in objectOrder: for name in objectOrder:
primitives = [] primitives = []
# The pivot is measured across the whole object, not per material, or the parts of one wing
# would each turn about a different point.
whole = np.concatenate([positions[[v for v, vt, vn in primitive['vertices']]] for primitive in objects[name].values()])
pivot = pivotFor(name, whole)
for materialName, primitive in objects[name].items(): for materialName, primitive in objects[name].items():
verts = primitive['vertices'] verts = primitive['vertices']
pos = positions[[v for v, vt, vn in verts]] pos = positions[[v for v, vt, vn in verts]]
if pivot is not None:
pos = pos - np.array(pivot, dtype=pos.dtype)
attributes = {'POSITION': addAccessor(pos, 5126, 'VEC3', 34962, bounds=True)} attributes = {'POSITION': addAccessor(pos, 5126, 'VEC3', 34962, bounds=True)}
if normals is not None and all(vn is not None for v, vt, vn in verts): if normals is not None and all(vn is not None for v, vt, vn in verts):
attributes['NORMAL'] = addAccessor(normals[[vn for v, vt, vn in verts]], 5126, 'VEC3', 34962) attributes['NORMAL'] = addAccessor(normals[[vn for v, vt, vn in verts]], 5126, 'VEC3', 34962)
@ -135,7 +182,10 @@ for name in objectOrder:
entry['material'] = materialNames.index(materialName) entry['material'] = materialNames.index(materialName)
primitives.append(entry) primitives.append(entry)
meshes.append({'name': name, 'primitives': primitives}) meshes.append({'name': name, 'primitives': primitives})
nodes.append({'name': name, 'mesh': len(meshes) - 1}) node = {'name': name, 'mesh': len(meshes) - 1}
if pivot is not None:
node['translation'] = pivot
nodes.append(node)
rootName = args.root or os.path.splitext(os.path.basename(args.out))[0] rootName = args.root or os.path.splitext(os.path.basename(args.out))[0]
nodes.append({'name': rootName, 'children': list(range(len(nodes)))}) nodes.append({'name': rootName, 'children': list(range(len(nodes)))})

224
util/renderMenuVideo.py Normal file
View file

@ -0,0 +1,224 @@
# Records the menu's backdrop -- Singe/Backdrop.singe, the intro and the grid behind it -- to the
# video a machine with no GPU plays in its place. The drawn backdrop needs the 3D scene, and the
# 3D scene needs a GPU device; Singe/MenuOverlay.singe runs where there is none, and what it has to
# put on the screen instead is a recording of what everybody else draws.
#
# The recording is made by the engine itself rather than by a renderer of our own, so there is one
# backdrop and not two: a headless Singe draws Backdrop.singe on a virtual clock that moves a fixed
# number of milliseconds a frame, shoots every frame, and the frames become the video. Nothing here
# knows how long the intro lasts or how fast the grid moves -- the script asks the backdrop and
# prints the answer, which is the only way those numbers cannot drift apart.
#
# The result is intro frames followed by a whole number of turns of the grid, so playing the tail
# over and over is seamless. The three frame numbers the menu needs are printed at the end; they
# go into Singe/Menu.singe as DISC_MENU_FRAME, DISC_GRID_START and DISC_LAST_FRAME.
#
# The sound comes with it: util/makeMenuSound.py writes assets/menuIntro.flac to the very length
# this measured, and Singe/Menu.singe plays that file over whichever renderer is drawing. The
# video has no audio track at all -- muxing the sound in as well would be the same nine seconds
# shipped twice in one binary -- and nothing plays over the looping section, because the music has
# faded out before the loop begins and a menu waiting for someone to choose a game should be quiet.
#
# The engine extracts its own copy of Backdrop.singe over anything in the run directory, so what is
# recorded is what is embedded in the binary: build before rendering, or the recording is of the
# last build. The script checks and says so.
#
# Usage: python3 util/renderMenuVideo.py
# python3 util/renderMenuVideo.py --periods 8 --keep
import argparse
import os
import re
import shutil
import subprocess
import sys
from makeMenuSound import makeSound
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STEP_MS = 33 # Milliseconds a frame: the virtual clock's step and the video's rate.
# 33 is a thirtieth of a second to the nearest millisecond -- the clock
# takes whole milliseconds -- and 660, one turn of the grid, divides by
# it exactly. Change one of the two and the loop stops meeting itself;
# the driver script below checks rather than trusting this comment.
KEY_EVERY = 15 # Frames between keyframes. The menu seeks to the start of the loop on
# every turn, so that frame has to be one of them; the driver script
# below checks that the backdrop put its loop on one.
PERIODS = 6 # Turns of the grid kept as the loop. One would do for the picture; the
# extra ones are room for the bed underneath to breathe in.
WIDTH = 1024 # Rendered size. Four by three, and the biggest the offscreen video
HEIGHT = 768 # driver will give: a neon line lands on the video's pixels as an
# average of the several it crossed rather than as whichever one it hit.
VIDEO_W = 720 # What the menu's overlay is laid out in, and what the old clips were:
VIDEO_H = 480 # standard definition, which is all this has to be.
CRF = 24 # How hard it is compressed. Flat colour with hard edges is easy to
# encode: at 18 the file was nearly twice this size and no different to
# look at, on the sun's edge or on the grid in motion, which is where
# ringing would show if it were going to.
CANVAS = "%dx%d" % (VIDEO_W, VIDEO_H)
DRIVER = '''-- Written by util/renderMenuVideo.py. Records Singe/Backdrop.singe frame by frame.
dofile("Singe/Backdrop.singe")
local STEP = %(step)d / 1000.0
local PERIODS = %(periods)d
local KEY = %(key)d
-- One turn of the grid has to be a whole number of frames or the loop cannot meet itself.
local perLoop = backdropLoopSeconds() / STEP
local loopFrames = math.floor(perLoop + 0.5)
if math.abs(perLoop - loopFrames) > 0.0001 then
error(string.format("the grid's %%g second turn is %%g frames at %%d ms; it has to be whole", backdropLoopSeconds(), perLoop, %(step)d))
end
loopFrames = loopFrames * PERIODS
-- Where the backdrop hands over from its intro to its loop. The backdrop decides, because the
-- sound it plays hands over at the same moment; all that is checked here is that the moment it
-- picked is a frame at all, and a keyframe, so the menu's seek back to it is instant.
local introSeconds = backdropLoopAt()
local introFrames = introSeconds / STEP
if math.abs(introFrames - math.floor(introFrames + 0.5)) > 0.0001 then
error(string.format("the loop begins at %%g seconds, which is not a frame at %%d ms", introSeconds, %(step)d))
end
introFrames = math.floor(introFrames + 0.5)
if introFrames %% KEY ~= 0 then
error(string.format("the loop begins on frame %%d, which is not one of the every %%d keyframes", introFrames, KEY))
end
if introSeconds < backdropIntroSeconds() then
error("the loop begins before the intro has finished")
end
-- And where the menu itself may take the screen, which is earlier: the last seconds of the intro
-- are the menu already up with the music leaving behind it.
local menuFrame = math.ceil(backdropIntroSeconds() / STEP)
local total = introFrames + loopFrames
local frame = 0
debugPrint(string.format("RENDER intro=%%d loop=%%d menu=%%d total=%%d step=%%d", introFrames, loopFrames, menuFrame, total, %(step)d))
-- What Singe/Menu.singe lays the overlay out in. A game without a disc gets half its canvas by
-- default, and the tagline -- measured and centred against whatever this says -- came out too wide
-- for it and was cut off at both ends.
overlaySetResolution(%(width)d, %(height)d)
backdropBegin(true)
function onOverlayUpdate()
-- The two lines Singe/MenuDocument.singe puts in front of the backdrop, so the recording is
-- made the way the menu draws it and not some other way.
colorBackground(0, 0, 0, 0)
overlayClear()
backdropFrame()
singeScreenshot()
frame = frame + 1
if frame >= total then
singeQuit()
end
return OVERLAY_UPDATED
end
'''
def findBinary():
folder = os.path.join(REPO, ".builddir", "linux-gcc", "x86_64", "singe")
for name in sorted(os.listdir(folder)):
path = os.path.join(folder, name)
if name.startswith("Singe") and os.path.isfile(path) and os.access(path, os.X_OK):
return path
raise SystemExit("no Singe binary in %s; build first" % folder)
def render(binary, work, frames, step):
# Offscreen rather than under an X server: Xvfb has no DRI3, so a hardware Vulkan device
# cannot build a swapchain there and SDL quietly falls back to the software rasteriser.
env = dict(os.environ)
env["SDL_VIDEODRIVER"] = "offscreen"
env["SDL_AUDIO_DRIVER"] = "dummy"
env["SDL_AUDIODRIVER"] = "dummy"
command = [binary, "-k", "-s", "-C", CANVAS, "-x", str(WIDTH), "-y", str(HEIGHT),
"--deterministic=%d" % step, "-d", frames + os.sep, "render.singe"]
result = subprocess.run(command, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
text = result.stdout.decode("utf-8", "replace")
if result.returncode != 0:
sys.stdout.write(text)
raise SystemExit("the engine failed with %d" % result.returncode)
return text
def main():
parser = argparse.ArgumentParser(description="record the menu backdrop to a video")
parser.add_argument("--out", default=os.path.join(REPO, "assets", "menuBackground.mkv"))
parser.add_argument("--periods", type=int, default=PERIODS, help="turns of the grid kept as the loop")
parser.add_argument("--step", type=int, default=STEP_MS, help="milliseconds a frame")
parser.add_argument("--binary", help="the engine to record with, in place of the built one")
parser.add_argument("--keep", action="store_true", help="leave the frames behind")
args = parser.parse_args()
binary = args.binary or findBinary()
work = os.path.join(REPO, ".builddir", "menuVideo")
# The frames are screenshots, and screenshots belong in the repository's screenshots folder.
frames = os.path.join(REPO, "screenshots", "menuBackdrop")
shutil.rmtree(frames, ignore_errors=True)
os.makedirs(frames)
os.makedirs(work, exist_ok=True)
with open(os.path.join(work, "render.singe"), "w") as out:
out.write(DRIVER % {"step": args.step, "periods": args.periods, "key": KEY_EVERY,
"width": VIDEO_W, "height": VIDEO_H})
print("recording with %s" % os.path.basename(binary))
text = render(binary, work, frames, args.step)
found = re.search(r"RENDER intro=(\d+) loop=(\d+) menu=(\d+) total=(\d+) step=(\d+)", text)
if not found:
sys.stdout.write(text)
raise SystemExit("the engine never said how long the backdrop is")
intro = int(found.group(1))
loop = int(found.group(2))
menu = int(found.group(3))
total = int(found.group(4))
# The engine writes its own Backdrop.singe over the run directory's, so a stale binary records
# a stale backdrop without any sign of it. Say so rather than shipping the wrong picture.
mine = open(os.path.join(REPO, "assets", "Backdrop.singe"), "rb").read()
theirs = open(os.path.join(work, "Singe", "Backdrop.singe"), "rb").read()
if mine != theirs:
print("WARNING: the binary's Backdrop.singe is not the one in assets; rebuild and render again")
shots = [f for f in os.listdir(frames) if f.endswith(".png")]
if len(shots) != total:
raise SystemExit("wanted %d frames and got %d" % (total, len(shots)))
# The sound, written to the length the picture just measured. It is not muxed into the video:
# Singe/Menu.singe plays this file over whichever renderer is drawing, so a copy in the video's
# audio track would be the same nine seconds of sound shipped twice inside the same binary.
sting = makeSound(intro * args.step / 1000.0, os.path.dirname(args.out))
# The frames are square pixels at four by three; the video is 720x480, which the engine shows at
# four by three as well, so this is a scale and not a crop. A keyframe every KEY_EVERY frames
# keeps the menu's seek to the top of the loop instant and the engine's keyframe warning quiet.
# No audio track: the menu plays the sound itself, over this or over the drawn backdrop.
command = ["ffmpeg", "-y", "-loglevel", "error",
"-framerate", "1000/%d" % args.step, "-i", os.path.join(frames, "singe%03d.png"),
"-an", "-vf", "scale=%d:%d:flags=lanczos" % (VIDEO_W, VIDEO_H),
"-c:v", "libx264", "-crf", str(CRF), "-pix_fmt", "yuv420p",
"-force_key_frames", "expr:eq(mod(n,%d),0)" % KEY_EVERY,
"-f", "matroska", args.out]
subprocess.run(command, check=True)
if not args.keep:
shutil.rmtree(frames, ignore_errors=True)
print("%s: %d frames at %g fps, %.2f seconds" % (os.path.relpath(args.out, REPO), total, 1000.0 / args.step, total * args.step / 1000.0))
print("intro %d frames, loop %d frames (%d turns of the grid)" % (intro, loop, args.periods))
print("sound: %s" % os.path.basename(sting))
print("Singe/Menu.singe: DISC_MENU_FRAME = %d, DISC_GRID_START = %d, DISC_LAST_FRAME = %d" % (menu, intro, total - 1))
# And whether that is what it says today. The menu cannot read these out of the video, so they
# are written down in two places and this is what stops the two from parting company.
written = open(os.path.join(REPO, "assets", "Menu.singe"), encoding="utf-8").read()
for name, value in (("DISC_MENU_FRAME", menu), ("DISC_GRID_START", intro), ("DISC_LAST_FRAME", total - 1)):
if ("%s = %d" % (name, value)) not in written:
print("WARNING: assets/Menu.singe does not say %s = %d; it has to" % (name, value))
main()