Working on Forge, new logo, better menus.
This commit is contained in:
parent
c3b82d2627
commit
08415da805
46 changed files with 5767 additions and 470 deletions
213
CHANGELOG
213
CHANGELOG
|
|
@ -26,6 +26,49 @@ API Changes
|
|||
the command line keep their values. Every game in a library therefore
|
||||
behaves the same whether it was started from the menu or from a shell.
|
||||
|
||||
- A game can be described rather than written. Singe/Author.singe and
|
||||
Singe/AuthorCompile.singe take a table of layers, entities, behaviours
|
||||
and rules and compile it into an ordinary Singe game: the rules become
|
||||
real Lua, so nothing walks a table every frame and the result can be
|
||||
read and edited by hand. There is no notion of genre in it -- a game
|
||||
declares which of the engine's layers it uses, and that is the whole
|
||||
difference between a platformer and a quick-time event over video. The
|
||||
vocabulary of conditions and actions is declared in a table rather than
|
||||
built into the compiler, so new kinds of game are entries rather than
|
||||
releases, and the "lua" action is the deliberate way out when a rule
|
||||
needs something the vocabulary cannot say. See the manual.
|
||||
|
||||
- An editor for those descriptions, Forge, which is itself
|
||||
a Singe game: the canvas is the same overlay at the same coordinates the
|
||||
game will be played in, so what is placed is what is seen. Entity list
|
||||
and details are an RmlUi document, the canvas beside them is drawn into
|
||||
the overlay and picked the way a light gun game picks a target, and the
|
||||
two compose because a button is offered to the GUI first while pointer
|
||||
motion is never consumed. A description survives a round trip through
|
||||
it: load, save, load again, and it compiles to the same game. Forge is
|
||||
distributed on its own and no part of it ships inside Singe -- not the
|
||||
editor, not the compiler, not the runtime. A game Forge builds carries
|
||||
its own copy of that runtime, so it runs on a machine that has never had
|
||||
Forge on it and cannot change behaviour because the engine moved on.
|
||||
|
||||
- A game released from Forge is standalone. forgeExport writes the
|
||||
compiled script, a games.dat, the description it came from and a copy of
|
||||
the runtime into a directory of its own, taken out of Forge. --pack turns
|
||||
that directory into a .game. The game locates its own directory with
|
||||
debug.getinfo rather than trusting DIR, which names the directory of the
|
||||
script the engine was launched with -- not this one when a game is reached
|
||||
by dofile.
|
||||
|
||||
- The editor edits rules, not only entity positions: Tab swaps the panel
|
||||
between the entities and the event sheet, the selected rule opens in
|
||||
place with its conditions and actions under it, and conditions and
|
||||
actions are added from the same manifest the compiler reads, so the rule
|
||||
editor needs no change when the vocabulary grows. It is driven by keys
|
||||
as well as the pointer, which is how the bundled menu has always worked
|
||||
and what a cabinet needs. ENTER types a value into the selected
|
||||
condition or action and moves to its next one, ESC puts it back; a
|
||||
number typed in comes back a number.
|
||||
|
||||
- The name now expands to "SINGE Is Not a Game Emulator", in the banner,
|
||||
the manual, INSTALL, and the Windows file description.
|
||||
|
||||
|
|
@ -995,6 +1038,98 @@ Fixes
|
|||
gun dead with only "No mice detected" to explain it. Only --manymouse
|
||||
needs those devices now, and the message names the group.
|
||||
|
||||
- The menu's intro is drawn, not played, and runs straight into the menu.
|
||||
A charge goes off, the Singe dragon and name fly out of it toward the
|
||||
camera with the dragon's wings beating, the tagline fades in, the dragon
|
||||
rears and breathes flame -- and then the grid the menu sits on comes up
|
||||
out of the dark beneath the logo while the smoke is still clearing, the
|
||||
logo lifts away, and the menu takes the screen. The dragon settles
|
||||
centred on the sun rather than above it, with the name below crossing the
|
||||
horizon, so the sun is a disc behind the animal instead of a rim around
|
||||
the pair of them. It is one scene from the
|
||||
first frame to the last: there is no cut and nothing loads at the join,
|
||||
and the menu takes input when the logo has gone rather than when a video
|
||||
frame number passes. Returning from a game starts the same timeline past
|
||||
the end of the intro, so it lands on the settled backdrop. The two
|
||||
models it is made of are shipped with the engine like the font and the
|
||||
logos.
|
||||
|
||||
- The menu background video is a recording of that same backdrop, so the
|
||||
machine with no GPU sees what everybody else draws instead of a
|
||||
different intro and a different grid. util/renderMenuVideo.py makes it:
|
||||
a headless Singe draws Singe/Backdrop.singe on a virtual clock that
|
||||
moves a fixed number of milliseconds a frame, shoots every one of them,
|
||||
and the frames become assets/menuBackground.mkv. Nothing in the script
|
||||
knows how long the intro runs or how fast the grid moves -- it asks the
|
||||
backdrop, which is the only way the recording and the drawing cannot
|
||||
drift apart -- and it stops after a whole number of turns of the grid,
|
||||
so the tail the menu plays over and over meets itself exactly. The two
|
||||
clips the old video was cut from are gone, one of which nobody could
|
||||
account for.
|
||||
|
||||
- The menu has a sound. util/makeMenuSound.py writes it -- every noise in
|
||||
it is an oscillator, a burst of noise or an envelope over one of the two,
|
||||
in a room that is a convolution with a tail of decaying noise, so there is
|
||||
nothing in it that cannot be accounted for either. The charge, the rush
|
||||
of the logo arriving, the wingbeats (on the same curve the animation flaps
|
||||
to), the flame, and a riser as the grid comes up, over a bed whose four
|
||||
bars land exactly where the menu takes the screen.
|
||||
The charge and the flame are built the way the things themselves are
|
||||
heard rather than the way they are drawn. Both were made of smooth
|
||||
envelopes over steady noise -- the flame of a band of noise wobbled by two
|
||||
sine waves, which is the textbook fake fire -- and nothing about either is
|
||||
smooth, steady or periodic. The blast is now a crack, a slam through the
|
||||
middle of the bass (the band a small speaker turns into "loud", where the
|
||||
sub it had before moves no air at all), a drop that falls away twice over
|
||||
and is saturated so its harmonics carry the pitch a cabinet speaker cannot
|
||||
make, a body that flickers at random as its top closes, and rubble coming
|
||||
down after it; the fire is a roar whose loudness wanders at random, a
|
||||
resonance that wanders with it, and the spitting inside it, all surging
|
||||
together. Everything else ducks under the blast and comes back over a
|
||||
quarter of a second, because punch is contrast, and the limiter only bends
|
||||
the very top so the loudest moment keeps the shape it was given. Then it fades out: music under a menu waiting for someone
|
||||
to choose a game wears out its welcome, so the last seconds of the intro
|
||||
are the grid running on while the sound leaves, and nothing loops. The
|
||||
The menu plays the file itself, over whichever renderer is drawing, so
|
||||
there is one copy of it: muxing it into the recording as well would be
|
||||
the same nine seconds of sound shipped twice inside one binary.
|
||||
|
||||
- The dragon model is rigged. util/objToGlb.py takes --pivots, a JSON
|
||||
naming where each object's own origin should sit, and moves the vertices
|
||||
so the node carries it back as a translation. assets/DragonPivots.json
|
||||
gives the dragon its joints, so wingL turns at the shoulder and head at
|
||||
the top of the neck; before, every part's node sat at the model's origin
|
||||
and turning a wing swung it around the middle of the animal. The model
|
||||
looks identical -- no part moves by so much as a thousandth -- but it can
|
||||
now be animated without rigging it again in script each time.
|
||||
|
||||
- A game whose art is missing shows that it is missing, rather than taking
|
||||
the engine down with it. An empty or wrong decorator path could open
|
||||
something that is not a file, report a nonsense length, and throw
|
||||
std::bad_alloc out of the GUI's texture loader -- a typo in a games.dat
|
||||
ended the program. The loader now refuses an impossible size, and both
|
||||
menu renderers fall back to Singe/missing.png, drawn at build time, so a
|
||||
bad path reads as "MISSING art" instead of a hole in the page.
|
||||
|
||||
- GUIs and 2D particles no longer blink. guiDraw and emitterDraw are
|
||||
documented as lasting one frame, meaning one onOverlayUpdate -- but their
|
||||
queues were emptied after every rendered frame, and the script's turn is
|
||||
throttled to FRAME_TICK_MS while the display refreshes as often as it
|
||||
can. With a 3D scene enabled it refreshes continuously, so the GUIs and
|
||||
the 2D particles were drawn only on the frames the script happened to run
|
||||
on: in the menu, a game list flickering over a grid that did not. The
|
||||
queues are emptied when the script gets its turn instead.
|
||||
|
||||
- The menu shows packed games properly. A .game is a database the engine
|
||||
presents through the VFS, so lfs.attributes reports it as a directory,
|
||||
not a file -- and the menu tested the mode before the extension, so every
|
||||
container went down the loose-directory branch and its games.dat was
|
||||
loaded with no container recorded. Every CABINET, MARQUEE and ATTRACT
|
||||
path in it then named a file that exists only inside the database: a
|
||||
library of .game files showed no art at all and died with "Unable to
|
||||
open" on the first attract video. The extension is tested first now.
|
||||
Games packed with earlier releases are correct and do not need repacking.
|
||||
|
||||
- A lone .m2v played with --framefile now finds the .ogg beside it, as a
|
||||
framefile's segments always have. A Daphne era elementary stream
|
||||
carries no audio of its own, so lair.m2v played silently unless it was
|
||||
|
|
@ -1085,8 +1220,82 @@ Fixes
|
|||
copy differs from the running build. Upgrading the binary used to keep
|
||||
the old Framework.singe and Menu.singe forever.
|
||||
|
||||
- The menu background video is encoded with a keyframe every second, so
|
||||
the menu's jump to the game list is instant.
|
||||
- Lua reads a packed game where it lies. Its file handle is a C FILE * and
|
||||
a database row is not a file, so anything a script opened by name inside
|
||||
a .game was written out to data/<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
|
||||
video paths (the directory string was freed before use); a lone "." now
|
||||
|
|
|
|||
|
|
@ -173,6 +173,20 @@ add_custom_command(
|
|||
)
|
||||
singeEmbed(${GENERATED_DIR}/icon.png ${GENERATED_DIR}/icon.h "")
|
||||
singeEmbedImage(kangarooPunchLogo)
|
||||
|
||||
# The picture shown where a game's art should be. Drawn here rather than kept as a file: it is
|
||||
# ours, it needs no licence, and it cannot drift from what the code expects. Loud on purpose --
|
||||
# a blank space reads as a layout mistake, this reads as "your path is wrong".
|
||||
add_custom_command(
|
||||
OUTPUT ${GENERATED_DIR}/missing.png
|
||||
COMMAND ${IMAGEMAGICK} -size 256x256 pattern:checkerboard -auto-level -fill "#c0208090" -colorize 60
|
||||
-fill "#ffd84a" -pointsize 34 -gravity center -annotate +0-14 "MISSING"
|
||||
-fill "#ffffffcc" -pointsize 18 -annotate +0+22 "art"
|
||||
-bordercolor "#ffd84a" -border 4 ${GENERATED_DIR}/missing.png
|
||||
COMMENT "Drawing missing.png"
|
||||
VERBATIM
|
||||
)
|
||||
singeEmbed(${GENERATED_DIR}/missing.png ${GENERATED_DIR}/missing_png.h "")
|
||||
singeEmbedImage(singeLogo)
|
||||
|
||||
# Windows icon for the resource file.
|
||||
|
|
@ -190,8 +204,13 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg
|
|||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "")
|
||||
|
||||
# Forge, the authoring tool, is built from assets/Forge but is NOT embedded: it is distributed on
|
||||
# its own, not with the engine. Nothing of it -- not the editor, not the compiler, not the runtime
|
||||
# a game it builds loads -- belongs inside Singe.
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Backdrop.singe ${GENERATED_DIR}/Backdrop_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "")
|
||||
|
|
@ -203,19 +222,20 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rml ${GENERATED_DIR}/subtitle_rml
|
|||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rcss ${GENERATED_DIR}/subtitle_rcss.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.h "")
|
||||
|
||||
# Menu background video: two clips cropped to 4:3, scaled to 720x480, keyframed every second so the
|
||||
# menu's seeks are instant and the engine's keyframe warning stays quiet, and joined.
|
||||
file(WRITE ${GENERATED_DIR}/menuBackground.txt "file ${GENERATED_DIR}/menuBackground1.mkv\nfile ${GENERATED_DIR}/menuBackground2.mkv\n")
|
||||
add_custom_command(
|
||||
OUTPUT ${GENERATED_DIR}/menuBackground.mkv
|
||||
COMMAND ${FFMPEG_TOOL} -y -loglevel error -i "${CMAKE_SOURCE_DIR}/assets/Singe Engine Intro.mpg" -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -force_key_frames "expr:gte(t,n_forced)" -c:a aac -f matroska ${GENERATED_DIR}/menuBackground1.mkv
|
||||
COMMAND ${FFMPEG_TOOL} -y -loglevel error -i ${CMAKE_SOURCE_DIR}/assets/180503_01_PurpleGrid.mp4 -filter:v "crop=ih/3*4:ih,scale=720:480" -c:v libx264 -force_key_frames "expr:gte(t,n_forced)" -c:a aac -f matroska ${GENERATED_DIR}/menuBackground2.mkv
|
||||
COMMAND ${FFMPEG_TOOL} -y -loglevel error -f concat -safe 0 -i ${GENERATED_DIR}/menuBackground.txt -c copy ${GENERATED_DIR}/menuBackground.mkv
|
||||
DEPENDS "assets/Singe Engine Intro.mpg" assets/180503_01_PurpleGrid.mp4
|
||||
COMMENT "Building menuBackground.mkv"
|
||||
VERBATIM
|
||||
)
|
||||
singeEmbed(${GENERATED_DIR}/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "")
|
||||
# The two models the menu's intro is made of. Singe's own branding, so they belong with the engine
|
||||
# the way the font and the logos do -- the menu cannot reach into a game directory for them.
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/DragonModel.glb ${GENERATED_DIR}/DragonModel_glb.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/SingeText.glb ${GENERATED_DIR}/SingeText_glb.h "")
|
||||
|
||||
# Menu background video: a recording of the backdrop the menu draws, for the machines that cannot
|
||||
# draw it. It is not built here -- rendering it needs a GPU device and the engine itself, which is
|
||||
# the wrong way round for a build -- so it is an asset like the models are, made by
|
||||
# util/renderMenuVideo.py and re-made whenever assets/Backdrop.singe changes.
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "")
|
||||
|
||||
# The same sound the video carries, for the renderer that draws the backdrop instead of playing it.
|
||||
# util/renderMenuVideo.py writes the two together.
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuIntro.flac ${GENERATED_DIR}/menuIntro_flac.h "")
|
||||
|
||||
# Calibration click for the menu's audio delay screen: 20 ms of white noise with a fast fade.
|
||||
add_custom_command(
|
||||
|
|
|
|||
BIN
assets/180503_01_PurpleGrid.mp4
(Stored with Git LFS)
BIN
assets/180503_01_PurpleGrid.mp4
(Stored with Git LFS)
Binary file not shown.
623
assets/Backdrop.singe
Normal file
623
assets/Backdrop.singe
Normal 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)
BIN
assets/DragonModel.glb
(Stored with Git LFS)
Binary file not shown.
27
assets/DragonPivots.json
Normal file
27
assets/DragonPivots.json
Normal 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
546
assets/Forge/Author.singe
Normal 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
|
||||
357
assets/Forge/AuthorCompile.singe
Normal file
357
assets/Forge/AuthorCompile.singe
Normal 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
36
assets/Forge/Forge.rml
Normal 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">≡ Entities</div>
|
||||
<div id="title"></div>
|
||||
<div id="list"></div>
|
||||
<div id="detail"></div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
859
assets/Forge/Forge.singe
Normal file
859
assets/Forge/Forge.singe
Normal 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, "&", "&")
|
||||
out = string.gsub(out, "<", "<")
|
||||
out = string.gsub(out, ">", ">")
|
||||
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
local function refreshEntities()
|
||||
local rows = {}
|
||||
local index
|
||||
|
||||
for index, entity in ipairs(FORGE.game.entities) do
|
||||
local class = (index == FORGE.selected) and "row selected" or "row"
|
||||
|
||||
rows[#rows + 1] = string.format('<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 "≡ Rules" or "≡ Entities"
|
||||
if FORGE.mode == "rules" then
|
||||
refreshRules()
|
||||
else
|
||||
refreshEntities()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function forgeBegin(path)
|
||||
FORGE.path = path
|
||||
FORGE.game = authorLoad(path)
|
||||
if FORGE.game == nil then
|
||||
return false
|
||||
end
|
||||
FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight())
|
||||
FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml")
|
||||
guiSetInput(FORGE.gui, true)
|
||||
guiSetHandler(FORGE.gui, FORGE.document, "grip", "mousedown", function()
|
||||
local x = mouseGetPosition(0)
|
||||
|
||||
forgePanelGrab(x)
|
||||
end)
|
||||
guiSetHandler(FORGE.gui, FORGE.document, "grip", "mouseup", function()
|
||||
FORGE.panelDrag = nil
|
||||
FORGE.message = "panel at " .. FORGE.panelX
|
||||
forgeRefresh()
|
||||
end)
|
||||
forgePanelTo(0)
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- Moves the selected entity. The description is the truth; nothing is cached anywhere else, so
|
||||
-- saving is only writing it back out.
|
||||
function forgeMove(index, x, y)
|
||||
local entity = FORGE.game.entities[index]
|
||||
|
||||
entity.x = math.floor(x)
|
||||
entity.y = math.floor(y)
|
||||
FORGE.dirty = true
|
||||
forgeRefresh()
|
||||
end
|
||||
|
||||
|
||||
function forgeSave()
|
||||
authorSave(FORGE.game, FORGE.path)
|
||||
FORGE.dirty = false
|
||||
FORGE.message = "saved"
|
||||
forgeRefresh()
|
||||
|
||||
return FORGE.path
|
||||
end
|
||||
|
||||
|
||||
-- Everything a released game needs, in a directory of its own: the compiled script, the runtime
|
||||
-- it loads, its games.dat, and the description it was built from so it can be opened again.
|
||||
--
|
||||
-- Named export rather than release because forgeRelease is the mouse button coming up; "release"
|
||||
-- is a verb here and a noun there, and the two collided.
|
||||
--
|
||||
-- The runtime is copied out of Forge, which is the only place it exists: nothing of Forge ships
|
||||
-- inside Singe, so a game carries the copy it needs and stands on its own from then on.
|
||||
--
|
||||
-- The result is a directory --pack turns into a .game, and it runs on a machine that has never had
|
||||
-- Forge on it.
|
||||
function forgeExport(folder, name)
|
||||
local title = FORGE.game.title or "Untitled"
|
||||
local script = name or string.gsub(title, "[^%w]", "")
|
||||
local dat
|
||||
|
||||
if script == "" then
|
||||
script = "Game"
|
||||
end
|
||||
-- lfs.mkdir answers false when the directory is already there, which is not a failure.
|
||||
lfs.mkdir(folder)
|
||||
if lfs.attributes(folder, "mode") ~= "directory" then
|
||||
FORGE.message = "cannot make " .. folder
|
||||
forgeRefresh()
|
||||
return nil
|
||||
end
|
||||
|
||||
FORGE.game.source = FORGE.path
|
||||
local file = io.open(folder .. "/" .. script .. ".singe", "w")
|
||||
|
||||
if file == nil then
|
||||
FORGE.message = "cannot write into " .. folder
|
||||
forgeRefresh()
|
||||
return nil
|
||||
end
|
||||
file:write(authorCompile(FORGE.game))
|
||||
file:close()
|
||||
|
||||
if not authorCopy(AUTHOR_RUNTIME, folder .. "/Author.singe") then
|
||||
FORGE.message = "could not copy the runtime"
|
||||
forgeRefresh()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The description travels with the game, so the release can be opened and edited again.
|
||||
authorSave(FORGE.game, folder .. "/" .. script .. ".game")
|
||||
|
||||
-- A games.dat so the menu lists it, written from what the description already knows.
|
||||
dat = io.open(folder .. "/games.dat", "w")
|
||||
if dat ~= nil then
|
||||
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tDATA = %q,\n\t\tVIDEO = \"Singe/menuBackground.mkv\",\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
|
||||
title, folder .. "/" .. script .. ".singe", folder, overlayGetWidth(), overlayGetHeight()))
|
||||
dat:close()
|
||||
end
|
||||
|
||||
FORGE.message = "released to " .. folder
|
||||
forgeRefresh()
|
||||
|
||||
return folder
|
||||
end
|
||||
|
||||
|
||||
-- Compiles what is on screen and hands back the path, so the caller can dofile it and play.
|
||||
function forgeBuild(outputFile)
|
||||
FORGE.game.source = FORGE.path
|
||||
local file = assert(io.open(outputFile, "w"))
|
||||
|
||||
file:write(authorCompile(FORGE.game))
|
||||
file:close()
|
||||
FORGE.message = "built"
|
||||
forgeRefresh()
|
||||
|
||||
return outputFile
|
||||
end
|
||||
|
||||
|
||||
function forgePress(x, y)
|
||||
-- Whatever is under the chrome belongs to it, wherever it has been dragged to. The GUI has
|
||||
-- already had its chance at this click; checking again is belt and braces for the case where
|
||||
-- an element did not claim it.
|
||||
-- The tab first: it sits outside the panel, so it is reachable, and it is what moves the panel.
|
||||
local tx, ty, tw, th = forgeTab()
|
||||
|
||||
if collidePointRect(x, y, tx, ty, tw, th) then
|
||||
forgePanelGrab(x)
|
||||
return
|
||||
end
|
||||
if forgeOverPanel(x) then
|
||||
return
|
||||
end
|
||||
FORGE.held = forgePick(x, y)
|
||||
if FORGE.held ~= nil then
|
||||
local entity = FORGE.game.entities[FORGE.held]
|
||||
|
||||
FORGE.grabX = x - entity.x
|
||||
FORGE.grabY = y - entity.y
|
||||
forgeSelect(FORGE.held)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function forgeRelease()
|
||||
FORGE.held = nil
|
||||
FORGE.panelDrag = nil
|
||||
end
|
||||
|
||||
|
||||
-- Whether an x lies under the chrome as it is placed right now.
|
||||
function forgeOverPanel(x)
|
||||
return (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W)
|
||||
end
|
||||
|
||||
|
||||
-- Moves the chrome. Clamped so a panel dragged off the edge can always be got back.
|
||||
function forgePanelTo(x)
|
||||
local limit = overlayGetWidth() - PANEL_W
|
||||
|
||||
FORGE.panelX = math.max(0, math.min(math.floor(x), limit))
|
||||
if FORGE.gui ~= nil then
|
||||
element("panel").style.left = FORGE.panelX .. "px"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- The tab that drags the panel, in overlay coordinates. It is deliberately drawn on the *canvas*,
|
||||
-- just outside the panel, rather than being an element in the document: a press on the canvas is
|
||||
-- the one pointer path this editor has been shown to receive reliably, whereas whether a document
|
||||
-- gets the press at all depends on the GUI routing in _guiPointer, which the spike in PLAN section
|
||||
-- 58 found silent under a window manager. Drawing the tab ourselves needs none of that.
|
||||
function forgeTab()
|
||||
return FORGE.panelX + PANEL_W, TAB_Y, TAB_W, TAB_H
|
||||
end
|
||||
|
||||
|
||||
function forgePanelGrab(x)
|
||||
FORGE.panelDrag = x - FORGE.panelX
|
||||
FORGE.message = "moving the panel"
|
||||
forgeRefresh()
|
||||
end
|
||||
|
||||
|
||||
function forgeDrag(x, y)
|
||||
if FORGE.panelDrag ~= nil then
|
||||
forgePanelTo(x - FORGE.panelDrag)
|
||||
elseif FORGE.held ~= nil then
|
||||
forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Draws the description. Note that this draws the *description*, not a running game: there are
|
||||
-- no nodes here and no physics, which is why an entity can be dragged through a wall.
|
||||
function forgeDraw(pointerX, pointerY)
|
||||
local index
|
||||
|
||||
colorBackground(18, 18, 26, 255)
|
||||
overlayClear()
|
||||
for index, entity in ipairs(FORGE.game.entities) do
|
||||
local bx, by, bw, bh = forgeBounds(entity)
|
||||
local look = entity.look or {}
|
||||
local row
|
||||
|
||||
-- Everything is drawn, including whatever is under the chrome. The panel is opaque and
|
||||
-- slides, so it covers rather than hides: drag it aside and what was beneath it is there,
|
||||
-- in the right place, and can be picked up. Clipping was the earlier answer and it lied.
|
||||
colorForeground(look.r or 180, look.g or 180, look.b or 190, 255)
|
||||
if look.kind == "text" then
|
||||
colorForeground(look.r or 235, look.g or 235, look.b or 245, 255)
|
||||
if (look.text or "") ~= "" then
|
||||
fontPrint(entity.x, entity.y, look.text)
|
||||
end
|
||||
overlayBox(bx, by, bx + bw, by + bh)
|
||||
else
|
||||
for row = math.floor(by), math.floor(by + bh) do
|
||||
overlayLine(bx, row, bx + bw, row)
|
||||
end
|
||||
end
|
||||
if index == FORGE.selected then
|
||||
colorForeground(255, 210, 70, 255)
|
||||
overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2)
|
||||
overlayBox(entity.x - HANDLE, entity.y - HANDLE, entity.x + HANDLE, entity.y + HANDLE)
|
||||
end
|
||||
end
|
||||
-- The panel's drag tab.
|
||||
local tx, ty, tw, th = forgeTab()
|
||||
local trow
|
||||
|
||||
colorForeground(70, 70, 96, 255)
|
||||
for trow = ty, ty + th do
|
||||
overlayLine(tx, trow, tx + tw, trow)
|
||||
end
|
||||
colorForeground(255, 207, 74, 255)
|
||||
overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16)
|
||||
overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26)
|
||||
overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36)
|
||||
|
||||
if pointerX ~= nil then
|
||||
colorForeground(255, 220, 60, 255)
|
||||
overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY)
|
||||
overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7)
|
||||
end
|
||||
guiDraw(FORGE.gui)
|
||||
end
|
||||
|
||||
|
||||
-- ===== The event sheet ========================================================================
|
||||
--
|
||||
-- Rules are where someone with little programming skill actually spends their time, so this is the
|
||||
-- half of the editor that matters. It is driven by keys rather than by the pointer: the bundled
|
||||
-- menu's document is driven by keys and the pad and has always worked that way, whereas whether a
|
||||
-- document receives the pointer is the open question recorded in PLAN section 58. A cabinet wants
|
||||
-- keys anyway.
|
||||
--
|
||||
-- The vocabulary comes from AUTHOR, so a rule editor never needs changing when a condition or an
|
||||
-- action is added: it lists whatever the manifest declares.
|
||||
|
||||
local DEFAULTS = {
|
||||
number = 0,
|
||||
string = "",
|
||||
boolean = true,
|
||||
entity = "",
|
||||
scancode = "SPACE",
|
||||
switch = "SWITCH_BUTTON1",
|
||||
expression = '""',
|
||||
lua = "-- your Lua here",
|
||||
file = ""
|
||||
}
|
||||
|
||||
|
||||
-- Every condition and action of a rule, flattened, so one index walks the whole thing.
|
||||
function forgeParts(rule)
|
||||
local parts = {}
|
||||
local item
|
||||
|
||||
for _, item in ipairs(rule.when or {}) do
|
||||
parts[#parts + 1] = { kind = "when", item = item }
|
||||
end
|
||||
for _, item in ipairs(rule.act or {}) do
|
||||
parts[#parts + 1] = { kind = "act", item = item }
|
||||
end
|
||||
|
||||
return parts
|
||||
end
|
||||
|
||||
|
||||
-- A new condition or action, with a value for each parameter the manifest declares, so it compiles
|
||||
-- the moment it is added rather than only once every field has been filled in.
|
||||
function forgeMakePart(set, name)
|
||||
local entry = set[name]
|
||||
local item = { name }
|
||||
local key
|
||||
local kind
|
||||
|
||||
if entry == nil then
|
||||
return nil
|
||||
end
|
||||
for key, kind in pairs(entry.params or {}) do
|
||||
if kind == "entity" and #FORGE.game.entities > 0 then
|
||||
item[key] = FORGE.game.entities[1].id
|
||||
else
|
||||
item[key] = DEFAULTS[kind]
|
||||
end
|
||||
end
|
||||
|
||||
return item
|
||||
end
|
||||
|
||||
|
||||
function forgeRuleAdd(kindName, name)
|
||||
local rule = FORGE.game.rules[FORGE.rule]
|
||||
local set = (kindName == "when") and AUTHOR.conditions or AUTHOR.actions
|
||||
local item = forgeMakePart(set, name)
|
||||
|
||||
if (rule == nil) or (item == nil) then
|
||||
FORGE.message = "no such " .. kindName
|
||||
return false
|
||||
end
|
||||
local parts
|
||||
local at
|
||||
|
||||
rule[kindName] = rule[kindName] or {}
|
||||
rule[kindName][#rule[kindName] + 1] = item
|
||||
FORGE.dirty = true
|
||||
FORGE.message = "added " .. name
|
||||
|
||||
-- Select what was just added, so its parameters can be set straight away. Conditions come
|
||||
-- before actions in the flattened list, so adding one shifts every action along: the new
|
||||
-- part is found by identity rather than by assuming it went on the end.
|
||||
parts = forgeParts(rule)
|
||||
for at = 1, #parts do
|
||||
if parts[at].item == item then
|
||||
FORGE.part = at
|
||||
end
|
||||
end
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
function forgePartDelete()
|
||||
local rule = FORGE.game.rules[FORGE.rule]
|
||||
local parts = forgeParts(rule)
|
||||
local part = parts[FORGE.part]
|
||||
local list
|
||||
local i
|
||||
|
||||
if part == nil then
|
||||
return false
|
||||
end
|
||||
list = rule[part.kind]
|
||||
for i = 1, #list do
|
||||
if list[i] == part.item then
|
||||
table.remove(list, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
FORGE.part = math.min(FORGE.part, #forgeParts(rule))
|
||||
FORGE.dirty = true
|
||||
FORGE.message = "deleted"
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- Changes one parameter of the selected condition or action. Numbers arrive as numbers so that a
|
||||
-- description keeps compiling to the same thing whether it was typed or dragged.
|
||||
function forgePartSet(key, value)
|
||||
local parts = forgeParts(FORGE.game.rules[FORGE.rule])
|
||||
local part = parts[FORGE.part]
|
||||
|
||||
if part == nil then
|
||||
return false
|
||||
end
|
||||
part.item[key] = value
|
||||
FORGE.dirty = true
|
||||
FORGE.message = key .. " = " .. tostring(value)
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
function forgeRuleNew(note)
|
||||
FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", when = {}, act = {} }
|
||||
FORGE.rule = #FORGE.game.rules
|
||||
FORGE.part = 0
|
||||
FORGE.dirty = true
|
||||
FORGE.message = "rule added"
|
||||
forgeRefresh()
|
||||
|
||||
return FORGE.rule
|
||||
end
|
||||
|
||||
|
||||
function forgeRuleDelete()
|
||||
if FORGE.game.rules[FORGE.rule] == nil then
|
||||
return false
|
||||
end
|
||||
table.remove(FORGE.game.rules, FORGE.rule)
|
||||
FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules))
|
||||
FORGE.part = 0
|
||||
FORGE.dirty = true
|
||||
FORGE.message = "rule deleted"
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- One condition or action as a line: its name, then its parameters in a fixed order so the same
|
||||
-- rule always reads the same way.
|
||||
function forgePartText(item)
|
||||
local keys = {}
|
||||
local out = {}
|
||||
local key
|
||||
|
||||
for key in pairs(item) do
|
||||
if key ~= 1 then
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
for _, key in ipairs(keys) do
|
||||
out[#out + 1] = key .. " " .. tostring(item[key])
|
||||
end
|
||||
|
||||
return item[1] .. (( #out > 0) and (": " .. table.concat(out, ", ")) or "")
|
||||
end
|
||||
|
||||
|
||||
function forgeMode(mode)
|
||||
FORGE.mode = mode
|
||||
forgeRefresh()
|
||||
end
|
||||
|
||||
|
||||
-- Keys. Point the engine's callback at this and the editor is usable without a pointer at all,
|
||||
-- which is how the bundled menu has always worked and what a cabinet needs.
|
||||
--
|
||||
-- TAB entities or rules
|
||||
-- UP / DOWN move within the list
|
||||
-- LEFT/RIGHT move the panel out of the way
|
||||
-- ENTER type a value for the selected condition or action; again for its next value
|
||||
-- ESC put the value back
|
||||
-- N new rule DELETE delete the selected rule or part
|
||||
-- S save B build
|
||||
function forgeKey(keysym, scancode)
|
||||
local rules = FORGE.game.rules or {}
|
||||
|
||||
-- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain
|
||||
-- integers, so the comparison is against .value. Comparing against the table itself is always
|
||||
-- false with a real keyboard; it only appeared to work because a test passed the table.
|
||||
if FORGE.editing ~= nil then
|
||||
if scancode == SCANCODE.RETURN.value then
|
||||
forgeEditNext()
|
||||
return
|
||||
end
|
||||
if scancode == SCANCODE.ESCAPE.value then
|
||||
forgeEditCancel()
|
||||
return
|
||||
end
|
||||
if forgeTyped(keysym) then
|
||||
return
|
||||
end
|
||||
elseif scancode == SCANCODE.RETURN.value then
|
||||
forgeEditNext()
|
||||
return
|
||||
end
|
||||
|
||||
if scancode == SCANCODE.TAB.value then
|
||||
forgeMode((FORGE.mode == "rules") and "entities" or "rules")
|
||||
|
||||
elseif scancode == SCANCODE.LEFT.value then
|
||||
forgePanelTo(FORGE.panelX - 40)
|
||||
elseif scancode == SCANCODE.RIGHT.value then
|
||||
forgePanelTo(FORGE.panelX + 40)
|
||||
|
||||
elseif FORGE.mode == "entities" then
|
||||
if scancode == SCANCODE.DOWN.value then
|
||||
forgeSelect(math.min((FORGE.selected or 0) + 1, #FORGE.game.entities))
|
||||
elseif scancode == SCANCODE.UP.value then
|
||||
forgeSelect(math.max((FORGE.selected or 2) - 1, 1))
|
||||
end
|
||||
|
||||
else
|
||||
-- In the rules, up and down walk the rule and its parts as one list: past the last part
|
||||
-- of a rule is the next rule, which is how it reads on screen.
|
||||
if scancode == SCANCODE.DOWN.value then
|
||||
local parts = forgeParts(rules[FORGE.rule] or {})
|
||||
|
||||
if FORGE.part < #parts then
|
||||
FORGE.part = FORGE.part + 1
|
||||
elseif FORGE.rule < #rules then
|
||||
FORGE.rule = FORGE.rule + 1
|
||||
FORGE.part = 0
|
||||
end
|
||||
FORGE.lastField = nil
|
||||
forgeRefresh()
|
||||
elseif scancode == SCANCODE.UP.value then
|
||||
if FORGE.part > 0 then
|
||||
FORGE.part = FORGE.part - 1
|
||||
elseif FORGE.rule > 1 then
|
||||
FORGE.rule = FORGE.rule - 1
|
||||
FORGE.part = #forgeParts(rules[FORGE.rule] or {})
|
||||
end
|
||||
FORGE.lastField = nil
|
||||
forgeRefresh()
|
||||
elseif scancode == SCANCODE.N.value then
|
||||
forgeRuleNew()
|
||||
elseif scancode == SCANCODE.DELETE.value then
|
||||
if FORGE.part > 0 then
|
||||
forgePartDelete()
|
||||
else
|
||||
forgeRuleDelete()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if scancode == SCANCODE.S.value then
|
||||
forgeSave()
|
||||
elseif scancode == SCANCODE.B.value then
|
||||
forgeBuild(singeGetDataPath() .. "preview.singe")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- ===== Typing a value =========================================================================
|
||||
--
|
||||
-- A rule editor that can add "when keyHeld" but cannot change SPACE to UP is most of the way to
|
||||
-- useless, which is the half of the job that matters for someone with little programming skill.
|
||||
--
|
||||
-- Keysyms arrive as characters the way Tools.singe takes them, so this needs nothing of the GUI:
|
||||
-- ENTER starts editing the selected condition or action, each press moves to its next parameter,
|
||||
-- and ESCAPE puts back what was there.
|
||||
|
||||
FORGE.editing = nil -- { key = name, text = typed so far, was = the value it started as }
|
||||
|
||||
|
||||
-- The parameters of a part, in the order the panel shows them, so typing walks them the same way.
|
||||
function forgeFieldNames(item)
|
||||
local keys = {}
|
||||
local key
|
||||
|
||||
for key in pairs(item) do
|
||||
if key ~= 1 then
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
|
||||
return keys
|
||||
end
|
||||
|
||||
|
||||
-- A typed value as the description should hold it. A number has to come back a number: "220" and
|
||||
-- 220 compile to different source, and a description that changed shape because a value was
|
||||
-- retyped would stop round-tripping.
|
||||
local function typedValue(text, was)
|
||||
local number = tonumber(text)
|
||||
|
||||
if (type(was) == "number") and (number ~= nil) then
|
||||
return number
|
||||
end
|
||||
if text == "true" then
|
||||
return true
|
||||
end
|
||||
if text == "false" then
|
||||
return false
|
||||
end
|
||||
if (type(was) ~= "string") and (number ~= nil) then
|
||||
return number
|
||||
end
|
||||
|
||||
return text
|
||||
end
|
||||
|
||||
|
||||
function forgeEditCommit()
|
||||
if FORGE.editing == nil then
|
||||
return false
|
||||
end
|
||||
forgePartSet(FORGE.editing.key, typedValue(FORGE.editing.text, FORGE.editing.was))
|
||||
FORGE.editing = nil
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
function forgeEditCancel()
|
||||
if FORGE.editing == nil then
|
||||
return false
|
||||
end
|
||||
forgePartSet(FORGE.editing.key, FORGE.editing.was)
|
||||
FORGE.editing = nil
|
||||
FORGE.message = "unchanged"
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- Starts editing, or moves on to the next parameter of the same part. Committing as it moves is
|
||||
-- what makes ENTER, ENTER, ENTER feel like filling in a form.
|
||||
function forgeEditNext()
|
||||
local rule = FORGE.game.rules[FORGE.rule]
|
||||
local parts = forgeParts(rule or {})
|
||||
local part = parts[FORGE.part]
|
||||
local names
|
||||
local at = 0
|
||||
local i
|
||||
|
||||
if part == nil then
|
||||
return false
|
||||
end
|
||||
names = forgeFieldNames(part.item)
|
||||
if #names == 0 then
|
||||
FORGE.message = part.item[1] .. " takes no values"
|
||||
forgeRefresh()
|
||||
return false
|
||||
end
|
||||
if FORGE.editing ~= nil then
|
||||
forgeEditCommit()
|
||||
end
|
||||
for i = 1, #names do
|
||||
if names[i] == FORGE.lastField then
|
||||
at = i
|
||||
end
|
||||
end
|
||||
at = (at % #names) + 1
|
||||
FORGE.lastField = names[at]
|
||||
FORGE.editing = { key = names[at], text = "", was = part.item[names[at]] }
|
||||
FORGE.message = "type a value for " .. names[at] .. ", ENTER for the next, ESC to put it back"
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- One typed character. Point the engine's onKeyPressed at forgeKey and this is reached from there.
|
||||
function forgeTyped(keysym)
|
||||
if FORGE.editing == nil then
|
||||
return false
|
||||
end
|
||||
if keysym == 8 then
|
||||
FORGE.editing.text = string.sub(FORGE.editing.text, 1, -2)
|
||||
elseif (keysym >= 32) and (keysym < 127) then
|
||||
FORGE.editing.text = FORGE.editing.text .. string.char(keysym)
|
||||
else
|
||||
return false
|
||||
end
|
||||
-- Shown as it is typed, so the value in the list is the value being entered.
|
||||
forgePartSet(FORGE.editing.key, (FORGE.editing.text == "") and FORGE.editing.was or typedValue(FORGE.editing.text, FORGE.editing.was))
|
||||
FORGE.message = FORGE.editing.key .. " = " .. FORGE.editing.text .. "_"
|
||||
forgeRefresh()
|
||||
|
||||
return true
|
||||
end
|
||||
26
assets/Forge/games.dat
Normal file
26
assets/Forge/games.dat
Normal 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."
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@
|
|||
</div>
|
||||
<div id="footer">
|
||||
<div id="hints">Up / Down: select Left / Right: page Start or button: play Service: tools</div>
|
||||
<button id="start">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of
|
||||
|
|
|
|||
|
|
@ -91,6 +91,42 @@ function loadGameAssets()
|
|||
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.
|
||||
function menuActive()
|
||||
return (not toolsActive()) and MENU_RENDER.backdropReady()
|
||||
|
|
@ -128,13 +164,13 @@ function onInputPressed(what)
|
|||
if GAME_SELECTED == 1 then
|
||||
gameSelect(GAME_COUNT)
|
||||
else
|
||||
gameSelect(math.max(1, GAME_SELECTED - MENU_RENDER.pageSize()))
|
||||
gameSelect(math.max(1, GAME_SELECTED - pageStep()))
|
||||
end
|
||||
elseif what == SWITCH_RIGHT then
|
||||
if GAME_SELECTED == GAME_COUNT then
|
||||
gameSelect(1)
|
||||
else
|
||||
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + MENU_RENDER.pageSize()))
|
||||
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
|
||||
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
|
||||
gameStart()
|
||||
|
|
@ -177,6 +213,13 @@ function onShutdown()
|
|||
unloadGameAssets()
|
||||
saveConfig(not SHUTDOWN_FROM_PUSH)
|
||||
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
|
||||
soundUnload(SND_CLICK)
|
||||
end
|
||||
|
|
@ -238,20 +281,26 @@ local function loadGamesDat(source, container)
|
|||
GAMES = {}
|
||||
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_COUNT = 0
|
||||
for dir in lfs.dir(".") do
|
||||
if dir ~= "." and dir ~= ".." then
|
||||
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
|
||||
if file == "games.dat" then
|
||||
loadGamesDat(dir .. "/games.dat", nil)
|
||||
end
|
||||
end
|
||||
elseif dirattr.mode == "file" and dir:sub(-5):lower() == ".game" then
|
||||
loadGamesDat(dir .. "/games.dat", dir)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -263,17 +312,28 @@ if GAME_COUNT == 0 then
|
|||
else
|
||||
overlaySetResolution(discGetWidth(), discGetHeight())
|
||||
|
||||
-- Where the engine intro ends and the grid section begins in Singe/menuBackground.mkv, and its
|
||||
-- last frame. A renderer that draws its own grid uses the first as the end of the intro and
|
||||
-- never reaches the second.
|
||||
DISC_GRID_START = 180
|
||||
DISC_LAST_FRAME = 359
|
||||
-- Three frames of Singe/menuBackground.mkv: where the menu may take the screen, where the
|
||||
-- grid section it loops begins, and its last frame. The first two are not the same moment --
|
||||
-- the menu comes up while the music is still fading out behind it, and only then does the
|
||||
-- picture start repeating. All three come from util/renderMenuVideo.py, which prints them
|
||||
-- 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
|
||||
|
||||
-- The service tools share this click with the audio delay tool.
|
||||
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
|
||||
SHOW_INTRO = true
|
||||
CONFIG_FILE = singeGetDataPath() .. "menu.dat"
|
||||
|
|
@ -304,6 +364,9 @@ else
|
|||
MENU_RENDER.buildList()
|
||||
|
||||
MENU_RENDER.backdropBegin(SHOW_INTRO)
|
||||
if SHOW_INTRO then
|
||||
SND_INTRO_VOICE = soundPlay(SND_INTRO)
|
||||
end
|
||||
|
||||
-- Prime the pump
|
||||
gameSelect(GAME_SELECTED)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@
|
|||
-- 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
|
||||
-- 320x75 and 320x200 proportions.
|
||||
dofile("Singe/Backdrop.singe")
|
||||
|
||||
|
||||
-- Layout constants follow.
|
||||
local MARGIN = 10
|
||||
local GAP = 8
|
||||
local LIST_W = 200
|
||||
|
|
@ -56,228 +60,16 @@ local MARQUEE_ASPECT_H = 75
|
|||
local VIDEO_ASPECT_W = 320
|
||||
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 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 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)
|
||||
|
|
@ -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
|
||||
-- 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 path = tostring(file or "")
|
||||
local path = menuArtPath(file)
|
||||
|
||||
if path:sub(1, 6):lower() ~= "singe/" then
|
||||
path = "../" .. path
|
||||
|
|
@ -345,13 +143,6 @@ local function menuRowClick(gui, document, id, event, value)
|
|||
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.
|
||||
local function menuScrollToSelection()
|
||||
local list = menuElement("list")
|
||||
|
|
@ -400,7 +191,7 @@ MENU_RENDER = {}
|
|||
|
||||
MENU_RENDER.begin = function()
|
||||
-- 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())
|
||||
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
|
||||
guiSetInput(GUI, true)
|
||||
|
|
@ -412,46 +203,31 @@ MENU_RENDER.finish = function()
|
|||
guiDelete(GUI)
|
||||
GUI = nil
|
||||
end
|
||||
backdropFinish()
|
||||
sceneEnable(false)
|
||||
end
|
||||
|
||||
|
||||
-- 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.
|
||||
-- The backdrop is its own file, because util/renderMenuVideo.py draws the same thing to record it.
|
||||
MENU_RENDER.backdropBegin = function(showIntro)
|
||||
backdropBuild()
|
||||
introPlaying = showIntro
|
||||
if showIntro then
|
||||
discPlay()
|
||||
else
|
||||
backdropShow()
|
||||
end
|
||||
backdropBegin(showIntro)
|
||||
end
|
||||
|
||||
|
||||
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.
|
||||
colorBackground(0, 0, 0, 0)
|
||||
overlayClear()
|
||||
backdropFrame()
|
||||
end
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
-- 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()
|
||||
local width = overlayGetWidth()
|
||||
local height = overlayGetHeight()
|
||||
|
|
@ -489,7 +265,6 @@ MENU_RENDER.buildList = function()
|
|||
for index = 1, GAME_COUNT do
|
||||
guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick)
|
||||
end
|
||||
guiSetHandler(GUI, DOCUMENT, "start", "click", menuStartClick)
|
||||
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, "description", menuEscape(game.DESCRIPTION))
|
||||
menuElement("description").scroll_top = 0
|
||||
detailScroll = 0
|
||||
detailClock = 0
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -528,12 +305,41 @@ MENU_RENDER.pageSize = function()
|
|||
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()
|
||||
-- Attract Mode Video, under the hole the document leaves for it
|
||||
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
|
||||
if scrollPending then
|
||||
menuScrollToSelection()
|
||||
end
|
||||
menuScrollDetails()
|
||||
guiDraw(GUI)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -29,31 +29,45 @@
|
|||
-- 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
|
||||
-- visually: up and down move one game, left and right move ten, and the line under the marquee
|
||||
-- says where in the library the selection is. A description too long for its column is clipped
|
||||
-- rather than scrolled, because up and down now belong to the list.
|
||||
-- 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. An entry with more to say than fits walks down by
|
||||
-- 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_Y = 25
|
||||
local VIDEO_W = 320
|
||||
local VIDEO_H = 200
|
||||
local MARQUEE_H = 75
|
||||
local MARGIN = 10 -- Around the page, and between the regions on it.
|
||||
local GAP = 8
|
||||
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 LABEL_W = 150
|
||||
local SUB_INDENT = 20
|
||||
local PAGE_ROWS = 10
|
||||
local WRAP_BREAK = " [!wb!] "
|
||||
local HINTS = "Up / Down: select Left / Right: page Start or button: play Service: tools"
|
||||
|
||||
local font = nil
|
||||
local introDone = false
|
||||
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 hintsX, hintsY = 0, 0
|
||||
local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0
|
||||
local spriteCabinet = nil
|
||||
local spriteMarquee = nil
|
||||
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 textLimit = 0
|
||||
|
||||
|
|
@ -65,6 +79,24 @@ local function field(value)
|
|||
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
|
||||
-- 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
|
||||
|
|
@ -235,33 +267,48 @@ end
|
|||
|
||||
|
||||
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
|
||||
|
||||
|
||||
-- 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()
|
||||
local width = overlayGetWidth()
|
||||
local height = overlayGetHeight()
|
||||
local sideW = math.floor(width * SIDE_FRAC)
|
||||
local sideX = width - MARGIN - sideW
|
||||
|
||||
marqueeW = VIDEO_W
|
||||
marqueeX = width - MARGIN_X - marqueeW
|
||||
marqueeY = MARGIN_Y
|
||||
marqueeW = sideW
|
||||
marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
|
||||
marqueeX = sideX
|
||||
marqueeY = MARGIN
|
||||
|
||||
videoW = VIDEO_W
|
||||
videoH = VIDEO_H
|
||||
videoX = width - MARGIN_X - videoW
|
||||
videoY = MARGIN_Y + MARQUEE_H + MARGIN_Y
|
||||
videoW = sideW
|
||||
videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
|
||||
videoX = sideX
|
||||
videoY = marqueeY + marqueeH + GAP
|
||||
|
||||
textW = VIDEO_W
|
||||
textX = width - MARGIN_X - textW
|
||||
textY = videoY + videoH + MARGIN_Y
|
||||
textH = height - MARGIN_Y - textY
|
||||
hintsX = MARGIN
|
||||
hintsY = height - MARGIN - HINT_H
|
||||
|
||||
cabinetX = MARGIN_X
|
||||
cabinetY = MARGIN_Y
|
||||
cabinetW = videoX - MARGIN_X - cabinetX
|
||||
cabinetH = height - MARGIN_Y - cabinetY
|
||||
textX = MARGIN
|
||||
textY = videoY + videoH + GAP
|
||||
textW = width - MARGIN - textX
|
||||
textH = hintsY - GAP - textY
|
||||
|
||||
cabinetX = MARGIN
|
||||
cabinetY = MARGIN
|
||||
cabinetW = sideX - GAP - cabinetX
|
||||
cabinetH = textY - GAP - cabinetY
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -271,25 +318,48 @@ end
|
|||
|
||||
|
||||
MENU_RENDER.showGame = function(game)
|
||||
spriteCabinet = spriteLoad(game.CABINET)
|
||||
spriteMarquee = spriteLoad(game.MARQUEE)
|
||||
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
|
||||
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
|
||||
-- them. The document menu gives them their own panel instead.
|
||||
local textBox = field(game.DESCRIPTION) .. WRAP_BREAK .. WRAP_BREAK ..
|
||||
"Year: " .. field(game.YEAR) .. WRAP_BREAK ..
|
||||
"Genre: " .. field(game.GENRE) .. WRAP_BREAK ..
|
||||
"Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
|
||||
-- The details, grouped the way the document renderer groups them: the short fields share a
|
||||
-- line, and the description comes last because it is the one that can run on. It used to come
|
||||
-- first, which meant a long description pushed every fact about the game off the bottom.
|
||||
local textBox = "Year: " .. field(game.YEAR) .. " Genre: " .. field(game.GENRE) ..
|
||||
" Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
|
||||
"Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK ..
|
||||
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK .. WRAP_BREAK ..
|
||||
"Singe Port: " .. field(game.CREATOR) .. WRAP_BREAK ..
|
||||
"Source: " .. field(game.SOURCE)
|
||||
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK ..
|
||||
"Singe port: " .. field(game.CREATOR) .. " Source: " .. field(game.SOURCE) .. WRAP_BREAK ..
|
||||
WRAP_BREAK .. field(game.DESCRIPTION)
|
||||
-- fontToSprite bakes the colour in, so it is set before the sprites are made rather than
|
||||
-- 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)
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -307,7 +377,13 @@ MENU_RENDER.hideGame = function()
|
|||
spriteUnload(handle)
|
||||
end
|
||||
end
|
||||
for _, handle in ipairs(textShadows) do
|
||||
if handle >= 0 then
|
||||
spriteUnload(handle)
|
||||
end
|
||||
end
|
||||
textSprites = {}
|
||||
textShadows = {}
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -319,7 +395,6 @@ end
|
|||
MENU_RENDER.frame = function()
|
||||
local x = 0
|
||||
local y = 0
|
||||
local shown = 0
|
||||
|
||||
-- Cabinet image
|
||||
x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5
|
||||
|
|
@ -328,32 +403,56 @@ MENU_RENDER.frame = function()
|
|||
|
||||
-- Marquee image
|
||||
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)
|
||||
|
||||
-- Attract mode video
|
||||
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
|
||||
|
||||
-- Which game this is, since there is no list to look at
|
||||
colorForeground(255, 211, 90, 255)
|
||||
fontPrint(cabinetX, cabinetY, string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT))
|
||||
-- Which game this is, since there is no list to look at. It heads the details rather than
|
||||
-- sitting over the cabinet artwork, where it was unreadable against whatever the picture was.
|
||||
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
|
||||
y = textY
|
||||
for _, handle in ipairs(textSprites) do
|
||||
-- Find height of font and number of lines that fit
|
||||
if textHeight == 0 and handle >= 0 then
|
||||
textHeight = spriteGetHeight(handle)
|
||||
textLimit = math.floor(textH / textHeight)
|
||||
colorForeground(0, 0, 0, 255)
|
||||
fontPrint(textX + SHADOW, textY + SHADOW, title)
|
||||
colorForeground(255, 211, 90, 255)
|
||||
fontPrint(textX, textY, title)
|
||||
|
||||
-- What the game is. More of it than fits walks down a line at a time and starts over, so
|
||||
-- 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
|
||||
if shown < textLimit then
|
||||
if handle >= 0 then
|
||||
spriteDraw(handle, textX, y)
|
||||
end
|
||||
y = y + textHeight + 1
|
||||
shown = shown + 1
|
||||
if #textSprites > textLimit then
|
||||
local rest = (last or (textFirst == 0)) and SCROLL_HOLD or SCROLL_STEP
|
||||
|
||||
if (singeGetTicks() - textClock) >= (rest * 1000) then
|
||||
textFirst = last and 0 or (textFirst + 1)
|
||||
textClock = singeGetTicks()
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
BIN
assets/Singe Engine Intro.mpg
(Stored with Git LFS)
BIN
assets/Singe Engine Intro.mpg
(Stored with Git LFS)
Binary file not shown.
|
|
@ -59,17 +59,15 @@ body {
|
|||
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 {
|
||||
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 {
|
||||
border: 2dp #6a86b8;
|
||||
border-radius: 4dp;
|
||||
}
|
||||
|
||||
#info {
|
||||
|
|
@ -106,7 +104,6 @@ body {
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #101828d0;
|
||||
border: 2dp #6a86b8;
|
||||
border-radius: 4dp;
|
||||
|
|
@ -118,11 +115,6 @@ body {
|
|||
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
|
||||
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
BIN
assets/menuBackground.mkv
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/menuIntro.flac
(Stored with Git LFS)
Normal file
BIN
assets/menuIntro.flac
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -122,7 +122,7 @@ function buildAll() {
|
|||
# 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.
|
||||
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
|
||||
# passing quietly because the path was wrong.
|
||||
|
|
|
|||
|
|
@ -454,4 +454,25 @@ ExternalProject_Add(singe
|
|||
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}")
|
||||
|
|
|
|||
214
docs/Manual.adoc
214
docs/Manual.adoc
|
|
@ -109,7 +109,9 @@ itself: `Singe/Menu.singe` asks <<singehasgpu,singeHasGpu>> at startup and
|
|||
loads one of two renderers.
|
||||
|
||||
`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`::
|
||||
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)
|
||||
MenuDocument.singe The menu drawn as a GUI document
|
||||
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.rcss Its style sheet, on top of gui.rcss
|
||||
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
|
||||
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
|
||||
`controls.dat`, `Framework.singe`, a file whose extension is `exe`, `sh`,
|
||||
`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
|
||||
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 from Singe 2.10
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
#include "generated/font.h"
|
||||
#include "generated/icon.h"
|
||||
#include "generated/kangarooPunchLogo.h"
|
||||
#include "generated/missing_png.h"
|
||||
#include "generated/singeLogo.h"
|
||||
#include "generated/Framework_singe.h"
|
||||
#include "generated/controls_cfg.h"
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
#include "generated/Tools_singe.h"
|
||||
#include "generated/Net_singe.h"
|
||||
#include "generated/Master_singe.h"
|
||||
#include "generated/Backdrop_singe.h"
|
||||
#include "generated/MenuDocument_singe.h"
|
||||
#include "generated/MenuOverlay_singe.h"
|
||||
#include "generated/Menu_rml.h"
|
||||
|
|
@ -46,7 +48,10 @@
|
|||
#include "generated/subtitle_rml.h"
|
||||
#include "generated/subtitle_rcss.h"
|
||||
#include "generated/FreeSansBold_ttf.h"
|
||||
#include "generated/DragonModel_glb.h"
|
||||
#include "generated/SingeText_glb.h"
|
||||
#include "generated/menuBackground_mkv.h"
|
||||
#include "generated/menuIntro_flac.h"
|
||||
#include "generated/click_wav.h"
|
||||
#include "generated/Manual_pdf.h"
|
||||
#include "generated/COPYING.h"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
// and the gradient's geometry that guiFragmentGradient reads, drawn over RmlUi's geometry like
|
||||
// any other draw. The custom "shader" decorator is refused.
|
||||
|
||||
#include <new>
|
||||
#include <math.h>
|
||||
#include <stdint.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.
|
||||
|
||||
// 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::FileInterface *files = Rml::GetFileInterface();
|
||||
Rml::FileHandle file = files->Open(source);
|
||||
|
|
@ -828,7 +833,20 @@ Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, con
|
|||
files->Seek(file, 0, SEEK_END);
|
||||
fileSize = files->Tell(file);
|
||||
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->Close(file);
|
||||
surface = IMG_LoadTyped_IO(SDL_IOFromMem(buffer.get(), fileSize), true, extension.c_str());
|
||||
|
|
|
|||
30
src/main.c
30
src/main.c
|
|
@ -299,6 +299,7 @@ static void _showHeader(void);
|
|||
static void _showUsage(const char *name, const char *message) __attribute__((noreturn));
|
||||
static void _startSDL(void);
|
||||
static void _stopSDL(void);
|
||||
static void _stripDatabaseNames(char *path);
|
||||
static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device);
|
||||
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 },
|
||||
{ "Net.singe", Net_singe, Net_singe_len },
|
||||
{ "Master.singe", Master_singe, Master_singe_len },
|
||||
{ "Backdrop.singe", Backdrop_singe, Backdrop_singe_len },
|
||||
{ "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len },
|
||||
{ "MenuOverlay.singe", MenuOverlay_singe, MenuOverlay_singe_len },
|
||||
{ "Menu.rml", Menu_rml, Menu_rml_len },
|
||||
{ "menu.rcss", menu_rcss, menu_rcss_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 },
|
||||
{ "menuIntro.flac", menuIntro_flac, menuIntro_flac_len },
|
||||
{ "click.wav", click_wav, click_wav_len },
|
||||
{ "Manual.pdf", Manual_pdf, Manual_pdf_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.
|
||||
char *createDataDir(const char *dataDirBase, const char *filename) {
|
||||
const char separator = utilGetPathSeparator();
|
||||
|
|
@ -1925,6 +1954,7 @@ char *createDataDir(const char *dataDirBase, const char *filename) {
|
|||
p[1] = '_';
|
||||
}
|
||||
}
|
||||
_stripDatabaseNames(start);
|
||||
path = utilCreateString("%s%s", dataDirBase, start);
|
||||
free(relative);
|
||||
utilFixPathSeparators(&path, true);
|
||||
|
|
|
|||
602
src/singe.c
602
src/singe.c
|
|
@ -24,6 +24,7 @@
|
|||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <errno.h>
|
||||
#include <ctype.h>
|
||||
|
||||
// SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec.
|
||||
#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 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 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_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.
|
||||
typedef enum IoHookModeE {
|
||||
IO_HOOK_READ = 0,
|
||||
IO_HOOK_OPEN = 1, // The mode string decides
|
||||
IO_HOOK_WRITE = 2
|
||||
IO_HOOK_INPUT = 0, // io.input: reads, and becomes the default input
|
||||
IO_HOOK_LINES = 1, // io.lines: reads, and hands back an iterator
|
||||
IO_HOOK_OPEN = 2, // The mode string decides
|
||||
IO_HOOK_WRITE = 3
|
||||
} IoHookModeE;
|
||||
|
||||
typedef enum OverlayResultE {
|
||||
|
|
@ -362,6 +367,32 @@ typedef struct LuaModuleS {
|
|||
size_t length;
|
||||
} 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 {
|
||||
const char *configName; // Table name in controls.cfg
|
||||
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 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 _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 _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 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 _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, const char *name, const char *mode, bool watch);
|
||||
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
|
||||
// the queue is empty for the next.
|
||||
// Both passes of _drawGuis are done: this frame's GUIs become the ones a pointer may land on.
|
||||
// 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) {
|
||||
memcpy(_global.guiShown, _global.guiDraws, sizeof(GuiDrawT) * (size_t)_global.guiDrawCount);
|
||||
_global.guiShownCount = _global.guiDrawCount;
|
||||
_global.guiDrawCount = 0;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3534,7 +3592,19 @@ static void _installFileHooks(lua_State *L) {
|
|||
static const struct {
|
||||
const char *name;
|
||||
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;
|
||||
|
||||
lua_getglobal(L, "dofile");
|
||||
|
|
@ -3543,6 +3613,7 @@ static void _installFileHooks(lua_State *L) {
|
|||
lua_getglobal(L, "loadfile");
|
||||
lua_pushcclosure(L, _luaLoadfile, 1);
|
||||
lua_setglobal(L, "loadfile");
|
||||
_luaFileRegister(L);
|
||||
lua_getglobal(L, "io");
|
||||
for (i = 0; i < sizeof(ioHooks) / sizeof(ioHooks[0]); i++) {
|
||||
lua_getfield(L, -1, ioHooks[i].name);
|
||||
|
|
@ -3550,6 +3621,11 @@ static void _installFileHooks(lua_State *L) {
|
|||
lua_pushcclosure(L, _luaIoHook, 2);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
// directory, then relative to the game root.
|
||||
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.
|
||||
static char *_luaFormat(lua_State *L, const char *method, const char *fmt, va_list args) {
|
||||
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.
|
||||
static int32_t _luaIoHook(lua_State *L) {
|
||||
IoHookModeE mode = (IoHookModeE)lua_tointeger(L, lua_upvalueindex(2));
|
||||
const char *modeString = NULL;
|
||||
const char *name = NULL;
|
||||
char *path = NULL;
|
||||
bool writing = (mode == IO_HOOK_WRITE);
|
||||
|
||||
if (lua_type(L, 1) == LUA_TSTRING) {
|
||||
name = lua_tostring(L, 1);
|
||||
if (mode == IO_HOOK_OPEN) {
|
||||
modeString = luaL_optstring(L, 2, "r");
|
||||
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);
|
||||
if (path == NULL) {
|
||||
// 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_replace(L, 1);
|
||||
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);
|
||||
|
|
@ -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.
|
||||
//
|
||||
// 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)) {
|
||||
intReturn = OVERLAY_NOT_UPDATED;
|
||||
_global.guiDrawCount = 0;
|
||||
particlesClearQueue2D();
|
||||
_callLua("onOverlayUpdate", ">i", &intReturn);
|
||||
if (intReturn == OVERLAY_UPDATED) {
|
||||
_global.refreshDisplay = true;
|
||||
|
|
@ -16026,7 +16613,6 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
|
|||
_subtitleQueue();
|
||||
_drawGuis(&_global.videoRect, false);
|
||||
_drawParticles2D(PARTICLE_OVER, &_global.videoRect);
|
||||
particlesClearQueue2D();
|
||||
if ((_global.bezelTexture != NULL) && _global.bezelInFront && !_global.overlayOnTop) {
|
||||
_bezelDraw();
|
||||
}
|
||||
|
|
|
|||
99
src/vfs.c
99
src/vfs.c
|
|
@ -55,7 +55,6 @@
|
|||
#define fileTell ftello
|
||||
#endif
|
||||
|
||||
#define CACHE_DIRECTORY "cache"
|
||||
#define OVERLAY_DIRECTORY "files"
|
||||
#define LIST_INITIAL_CAPACITY 64
|
||||
#define MAIN_SCHEMA "main"
|
||||
|
|
@ -65,7 +64,6 @@ typedef struct DatabaseS {
|
|||
char *path; // Database file as named by the caller
|
||||
char *loose; // Sibling directory holding loose overrides, 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
|
||||
int64_t chunkBytes;
|
||||
sqlite3 *db;
|
||||
|
|
@ -95,7 +93,6 @@ typedef struct TargetS {
|
|||
char *path; // Filesystem path, or the loose override candidate for a packed name
|
||||
char *key; // Normalised key inside the database
|
||||
char *overlay; // Overlay path for the key
|
||||
char *cache; // Cache path for the key
|
||||
} TargetT;
|
||||
|
||||
struct VfsStreamS {
|
||||
|
|
@ -115,7 +112,6 @@ static bool _assetDirectory(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 char *_bindListRange(DatabaseT *db, const char *key);
|
||||
static bool _cacheCurrent(const TargetT *target);
|
||||
static bool _climbsAbove(const char *path);
|
||||
static void _databaseClose(DatabaseT *db);
|
||||
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 _resolve(const char *name, TargetT *target);
|
||||
static void _targetFree(TargetT *target);
|
||||
static bool _writeFile(const char *path, const uint8_t *data, size_t bytes);
|
||||
|
||||
|
||||
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
|
||||
// put, "a/../../b" and "../b" do not.
|
||||
static bool _climbsAbove(const char *path) {
|
||||
|
|
@ -252,7 +228,6 @@ static void _databaseClose(DatabaseT *db) {
|
|||
free(db->path);
|
||||
free(db->loose);
|
||||
free(db->overlay);
|
||||
free(db->cache);
|
||||
free(db->gameDir);
|
||||
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) {
|
||||
char *stem = 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;
|
||||
if (target->db->overlay == NULL) {
|
||||
target->db->overlay = _overlayFor(_dataDirBase, _dataDir, prefix, false, OVERLAY_DIRECTORY);
|
||||
target->db->cache = _overlayFor(_dataDirBase, _dataDir, prefix, false, CACHE_DIRECTORY);
|
||||
}
|
||||
free(prefix);
|
||||
break;
|
||||
|
|
@ -739,10 +713,8 @@ static bool _resolve(const char *name, TargetT *target) {
|
|||
}
|
||||
target->path = utilCreateString("%s%s", target->db->loose, inner);
|
||||
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->overlay, false);
|
||||
utilFixPathSeparators(&target->cache, false);
|
||||
free(norm);
|
||||
|
||||
return true;
|
||||
|
|
@ -753,29 +725,10 @@ static void _targetFree(TargetT *target) {
|
|||
free(target->path);
|
||||
free(target->key);
|
||||
free(target->overlay);
|
||||
free(target->cache);
|
||||
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.
|
||||
char *vfsDatabaseStem(const char *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.
|
||||
// Writes land in the loose directory or the overlay, never the database. A packed asset opened for
|
||||
// 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
|
||||
// nothing holds yields the loose candidate, which fails to open like any missing file.
|
||||
// A filesystem path for the name, or NULL when the name resolves nowhere. Writes land in the loose
|
||||
// directory or the overlay, never the database.
|
||||
//
|
||||
// There is no path to a packed asset: it is a row in a database and it stays there. Reading one
|
||||
// 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) {
|
||||
TargetT target;
|
||||
char *path = NULL;
|
||||
char *directory = NULL;
|
||||
uint8_t *data = NULL;
|
||||
size_t bytes = 0;
|
||||
|
||||
if (!_resolve(name, &target)) {
|
||||
return NULL;
|
||||
|
|
@ -834,23 +788,10 @@ char *vfsFilePath(const char *name, bool forWriting) {
|
|||
}
|
||||
path = target.overlay;
|
||||
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 {
|
||||
path = target.path;
|
||||
target.path = NULL;
|
||||
}
|
||||
}
|
||||
_targetFree(&target);
|
||||
|
||||
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);
|
||||
}
|
||||
free(_container->overlay);
|
||||
free(_container->cache);
|
||||
_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
|
||||
// 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) {
|
||||
TargetT target;
|
||||
ListT list;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ void vfsInit(const char *container, const char *dataDirBase, const char
|
|||
bool vfsIsDatabase(const char *path);
|
||||
bool vfsIsDirectory(const char *name);
|
||||
bool vfsIsFilesystem(const char *name);
|
||||
bool vfsIsPacked(const char *name);
|
||||
char **vfsList(const char *name, int32_t *count);
|
||||
void vfsListFree(char **list, int32_t count);
|
||||
SDL_IOStream *vfsOpenIO(const char *name);
|
||||
|
|
|
|||
BIN
testScripts/Models/DragonModel.glb
(Stored with Git LFS)
BIN
testScripts/Models/DragonModel.glb
(Stored with Git LFS)
Binary file not shown.
52
testScripts/author/platformer.game
Normal file
52
testScripts/author/platformer.game
Normal 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' } } }
|
||||
}
|
||||
}
|
||||
37
testScripts/author/qte.game
Normal file
37
testScripts/author/qte.game
Normal 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' } } }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
-- Prototype: the menu intro. An explosion, the Singe dragon and name flying out of it toward the
|
||||
-- camera, and the tagline fading in underneath.
|
||||
-- Prototype: the menu intro, through to the menu's own backdrop, as one continuous shot.
|
||||
--
|
||||
-- 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
|
||||
-- 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.
|
||||
|
||||
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.
|
||||
|
|
@ -15,16 +21,46 @@ 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 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,
|
||||
-- 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. 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.
|
||||
local WING_JOINT = { 0.30, 4.50, 0.45 }
|
||||
local NECK_JOINT = { 0.30, 2.70, 0.00 }
|
||||
local HEAD_JOINT = { 1.00, 5.20, 0.00 }
|
||||
local MOUTH = { 3.55, 5.60, 0.00 }
|
||||
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.
|
||||
|
|
@ -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.
|
||||
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 frames = 0
|
||||
|
|
@ -58,19 +94,31 @@ sceneSetBloom(1.2, 0.22)
|
|||
sceneSetTonemap(TONEMAP_ACES)
|
||||
|
||||
|
||||
-- Gives a part the joint the model does not have: a hinge node at the joint, with the part hung
|
||||
-- off it and pushed back by the same amount, so the geometry stays where it was and turning the
|
||||
-- hinge turns the part about the joint. Returns the hinge to turn.
|
||||
local function hinge(root, name, joint, parent, parentJoint)
|
||||
-- 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)
|
||||
local pivot = nodeNew()
|
||||
|
||||
nodeSetParent(pivot, parent or nodeGetParent(part))
|
||||
nodeSetPosition(pivot, joint[1] - parentJoint[1], joint[2] - parentJoint[2], joint[3] - parentJoint[3])
|
||||
nodeSetParent(part, pivot)
|
||||
nodeSetPosition(part, -joint[1], -joint[2], -joint[3])
|
||||
if (part ~= nil) and (parent ~= nil) then
|
||||
local px, py, pz = nodeGetPosition(part)
|
||||
local qx, qy, qz = nodeGetPosition(parent)
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -87,7 +135,7 @@ end
|
|||
|
||||
|
||||
local blast = nodeNew()
|
||||
nodeSetPosition(blast, 0, 0, BLAST_Z)
|
||||
nodeSetPosition(blast, 0, BLAST_Y, BLAST_Z)
|
||||
|
||||
local smoke = emitterNew(blast)
|
||||
emitterSetMax(smoke, 400)
|
||||
|
|
@ -172,17 +220,18 @@ 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 ORIGIN = { 0, 0, 0 }
|
||||
local wingLeft = hinge(dragon, "wingL", { WING_JOINT[1], WING_JOINT[2], -WING_JOINT[3] }, nil, ORIGIN)
|
||||
local wingRite = hinge(dragon, "wingR", WING_JOINT, nil, ORIGIN)
|
||||
local neck = hinge(dragon, "neck", NECK_JOINT, nil, ORIGIN)
|
||||
local head = hinge(dragon, "head", HEAD_JOINT, neck, NECK_JOINT)
|
||||
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)
|
||||
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.
|
||||
local breathLight = lightNew(LIGHT_POINT)
|
||||
|
|
@ -213,8 +262,144 @@ local tagY = math.floor(overlayGetHeight() * 0.72)
|
|||
|
||||
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()
|
||||
nodeSetPosition(camera, 0, 1.2, CAM_Z)
|
||||
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
|
||||
cameraSet(camera)
|
||||
|
||||
|
||||
|
|
@ -235,14 +420,35 @@ function onOverlayUpdate()
|
|||
-- 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
|
||||
|
||||
nodeSetVisible(logo, true)
|
||||
nodeSetPosition(logo, 0, 0.4 * e, BLAST_Z + (REST_Z - BLAST_Z) * e)
|
||||
-- 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.
|
||||
|
|
@ -284,8 +490,10 @@ function onOverlayUpdate()
|
|||
|
||||
colorBackground(0, 0, 0, 0)
|
||||
overlayClear()
|
||||
if tag > 0 then
|
||||
colorForeground(235, 225, 240, math.floor(255 * tag))
|
||||
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(tagX, tagY, TAGLINE)
|
||||
end
|
||||
|
||||
|
|
@ -293,7 +501,7 @@ function onOverlayUpdate()
|
|||
shotAt = shotAt + 1
|
||||
singeScreenshot()
|
||||
end
|
||||
if t > 5.6 then
|
||||
if t > 8.2 then
|
||||
singeQuit()
|
||||
end
|
||||
|
||||
|
|
|
|||
9
testScripts/packedIo/games.dat
Normal file
9
testScripts/packedIo/games.dat
Normal 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
|
||||
}
|
||||
}
|
||||
3
testScripts/packedIo/lines.txt
Normal file
3
testScripts/packedIo/lines.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
alpha
|
||||
beta
|
||||
gamma
|
||||
2
testScripts/packedIo/numbers.txt
Normal file
2
testScripts/packedIo/numbers.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
12 34.5 0x10
|
||||
rest of it
|
||||
115
testScripts/packedIo/packedIo.singe
Normal file
115
testScripts/packedIo/packedIo.singe
Normal 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
|
||||
1
testScripts/packedIo/tail.txt
Normal file
1
testScripts/packedIo/tail.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
no newline at the end
|
||||
|
|
@ -8,7 +8,7 @@ local frames = 0
|
|||
local step = 0
|
||||
local note = "start"
|
||||
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.
|
||||
local steps = {
|
||||
|
|
|
|||
95
testScripts/scene52.singe
Normal file
95
testScripts/scene52.singe
Normal 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
51
testScripts/scene53.singe
Normal 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
168
testScripts/scene54.singe
Normal 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
176
testScripts/scene55.singe
Normal 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
91
testScripts/scene56.singe
Normal 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
521
util/makeMenuSound.py
Normal 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()
|
||||
|
|
@ -2,7 +2,13 @@
|
|||
# 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,
|
||||
# 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 json
|
||||
import os
|
||||
|
|
@ -13,6 +19,7 @@ parser = argparse.ArgumentParser()
|
|||
parser.add_argument('obj')
|
||||
parser.add_argument('out')
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -117,13 +124,53 @@ for name in materialNames:
|
|||
entry['doubleSided'] = True
|
||||
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 = []
|
||||
nodes = []
|
||||
for name in objectOrder:
|
||||
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():
|
||||
verts = primitive['vertices']
|
||||
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)}
|
||||
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)
|
||||
|
|
@ -135,7 +182,10 @@ for name in objectOrder:
|
|||
entry['material'] = materialNames.index(materialName)
|
||||
primitives.append(entry)
|
||||
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]
|
||||
nodes.append({'name': rootName, 'children': list(range(len(nodes)))})
|
||||
|
|
|
|||
224
util/renderMenuVideo.py
Normal file
224
util/renderMenuVideo.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue