Lots of menu work related to auto updates. Engine work for auto updates. Forge work for sharing games with the server. LOTS of documentation work.
This commit is contained in:
parent
721a0d120a
commit
25aa909c3d
74 changed files with 21111 additions and 18789 deletions
147
CHANGELOG
147
CHANGELOG
|
|
@ -9,6 +9,49 @@ SINGE 3.00
|
||||||
API Changes
|
API Changes
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
- Singe can update itself. Once a day the menu asks the master service
|
||||||
|
whether a newer release exists for this machine's platform, and says so in
|
||||||
|
a line under the game's details; nothing is fetched until a person opens
|
||||||
|
the new Engine Update service tool and presses the button. The tool
|
||||||
|
downloads the release, checks it against the digest the service published,
|
||||||
|
puts it beside the running Singe under the release's own name, sets the old
|
||||||
|
one aside as old-<name>, and restarts into the new one with --refresh, so
|
||||||
|
the support folder is rewritten by the version that now owns it and the
|
||||||
|
folder it replaces is kept as old-Singe-<version>. no_update_check = true
|
||||||
|
in settings.cfg turns the daily look off; the tool still works by hand.
|
||||||
|
Behind it: singeGetSystemInfo() reports platform and executable,
|
||||||
|
SINGE_UPDATE_CHECK says whether the look is on, and singeRelaunch(path
|
||||||
|
[, argument]) starts another executable when this one ends. Neither the
|
||||||
|
question nor the download needs an account.
|
||||||
|
|
||||||
|
- Beta builds have a version of their own. CMake's project version has to be
|
||||||
|
numbers, so the four it takes are used as major.minor.beta.which: 3.00.0.0
|
||||||
|
is v3.00 and 3.00.1.2 is v3.00b2, which is what the binary is named, what
|
||||||
|
the engine reports, and what the books carry as their revision. A beta
|
||||||
|
ranks below the release of the same number, so a cabinet on v3.00b2 is
|
||||||
|
offered v3.00 the day it appears, and the minor is compared as a number, so
|
||||||
|
3.09 precedes 3.10. The launcher ranks the binaries beside it the same way.
|
||||||
|
|
||||||
|
- A game can be sent to the master service for review from Forge. P in the
|
||||||
|
editor exports, packs, and uploads it; an upload is not in the catalogue
|
||||||
|
until it has been reviewed, and Forge shows where each version of yours
|
||||||
|
stands. Behind it: singePack(directory, database) packs a game from
|
||||||
|
a script exactly as --pack does, and the network layer can send a request
|
||||||
|
body straight from a file, so a game is never held in memory whole.
|
||||||
|
|
||||||
|
- The menu knows which of its games are out of date, and updates them from the
|
||||||
|
game's own page. A packed game now carries VERSION in its games.dat, so the
|
||||||
|
file itself says what it is wherever it ends up. The menu reads that out of
|
||||||
|
every games.dat it loads and asks which of them are out of date, no sign-in
|
||||||
|
needed; a game with a newer version wears an "update" mark in the list, the
|
||||||
|
line under its details names the version, and button 3 fetches it through
|
||||||
|
the same checked download the Get Games tool uses. Nothing about this
|
||||||
|
machine is kept anywhere but the question: the menu says which games and
|
||||||
|
versions it holds and is told which are newer, because a record of what a
|
||||||
|
cabinet has installed could never have stayed true. Forge gives every
|
||||||
|
description a permanent GAME_ID the first time it is saved and writes it
|
||||||
|
into the games.dat it exports, so a Forge game can be published and updated.
|
||||||
|
|
||||||
- overlayGetFontWidth and overlayGetFontHeight report the size in pixels of one
|
- overlayGetFontWidth and overlayGetFontHeight report the size in pixels of one
|
||||||
character cell of the built-in console font. overlayPrint counts in cells
|
character cell of the built-in console font. overlayPrint counts in cells
|
||||||
while every other drawing call counts in pixels, and until now nothing said
|
while every other drawing call counts in pixels, and until now nothing said
|
||||||
|
|
@ -930,10 +973,9 @@ API Changes
|
||||||
|
|
||||||
- Player handles. The name on a leaderboard is a handle, not an email
|
- Player handles. The name on a leaderboard is a handle, not an email
|
||||||
address: three to twenty characters of letters, digits, hyphens and
|
address: three to twenty characters of letters, digits, hyphens and
|
||||||
underscores, chosen once. An operator can rename somebody; a player
|
underscores, chosen once. A player cannot change one afterwards, and an
|
||||||
cannot, and an email address cannot be changed at all. Handles are
|
email address cannot be changed at all. A handle nobody should claim, or
|
||||||
checked against a list the operator maintains -- identities nobody
|
with no place on a screen in a public room, is refused when it is chosen.
|
||||||
should claim, and words with no place on a screen in a public room.
|
|
||||||
|
|
||||||
- Online: accounts, a game catalogue, and high scores. A cabinet can sign
|
- Online: accounts, a game catalogue, and high scores. A cabinet can sign
|
||||||
in to a master service (master.singeengine.com by default), browse every
|
in to a master service (master.singeengine.com by default), browse every
|
||||||
|
|
@ -967,20 +1009,16 @@ API Changes
|
||||||
is what a leaderboard is kept under. A game without one can be played
|
is what a leaderboard is kept under. A game without one can be played
|
||||||
but not ranked.
|
but not ranked.
|
||||||
|
|
||||||
A game the service withdraws keeps working on the cabinets that have it.
|
A game that stops being offered keeps working on the cabinets that have
|
||||||
All that stops is being offered an update, and the menu shows it as
|
it. All that stops is being offered an update, and the menu shows it as
|
||||||
"installed, no longer offered" so it can still be removed from there.
|
"installed, no longer offered" so it can still be removed from there.
|
||||||
|
|
||||||
scoreBegin() tells the service a play is starting, and the score that
|
scoreBegin() says a play is starting, so the score that follows carries
|
||||||
follows carries how long it took measured on the server's own clock.
|
how long it took as timed at the other end rather than on this machine.
|
||||||
That is the one number in a submission that has not been through the
|
It costs a game one call and nothing is refused on it.
|
||||||
player's machine. Nothing is rejected on it -- it is shown to the
|
|
||||||
operator beside the score, and the judgement stays a person's.
|
|
||||||
|
|
||||||
A submitted score is a claim and the design says so: the cabinet is the
|
A submitted score is a claim and the design says so: the cabinet is the
|
||||||
player's own machine. The service records who claimed what, rate limits
|
player's own machine, so nothing here pretends to verify one.
|
||||||
submissions, and flags outliers for a person rather than pretending to
|
|
||||||
verify them.
|
|
||||||
|
|
||||||
- Subtitles carried inside the video file: discGetSubtitleTracks(),
|
- Subtitles carried inside the video file: discGetSubtitleTracks(),
|
||||||
discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads
|
discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads
|
||||||
|
|
@ -994,6 +1032,51 @@ API Changes
|
||||||
New Features
|
New Features
|
||||||
------------
|
------------
|
||||||
|
|
||||||
|
- Forge can judge a shot by the picture rather than by a hitbox. lightSensor
|
||||||
|
is a behaviour of the framework now, the way a home laserdisc gun works: a
|
||||||
|
spot on the picture flashes on the frames a target is shown, and a shot is
|
||||||
|
good when the picture under the gun changes the way the spot does. It began
|
||||||
|
as five games' own vocabulary and was general all along, so it moved in, and
|
||||||
|
those five name no vocabulary of their own any more. pictureAt came with it,
|
||||||
|
a condition any rule can ask -- is the picture at this point bright, dark, or
|
||||||
|
neither -- for a flash hidden in a game's own video, a colour cue, or a lamp
|
||||||
|
in a scene; a game with no disc reads dark everywhere. Both are in the
|
||||||
|
book's vocabulary tables, which are generated from the manifest, and scene
|
||||||
|
100 covers them.
|
||||||
|
|
||||||
|
- The launcher runs the best Singe it finds, and the support folder can be
|
||||||
|
rewritten on demand. Menu.sh and Menu.bat read each Singe* name beside them
|
||||||
|
and rank it by this machine's architecture first and the version second.
|
||||||
|
Architecture comes first because a newer build for another machine cannot run
|
||||||
|
at all, and one game directory on a share is read by a Pi and a PC alike; it
|
||||||
|
is worked out when the launcher runs, from uname or PROCESSOR_ARCHITECTURE,
|
||||||
|
rather than baked in by whichever machine wrote the folder, and on a Mac a
|
||||||
|
universal build counts as native. Among the builds this machine can run,
|
||||||
|
v3.10 beats v3.09, since the minor is compared as a number, and a beta loses
|
||||||
|
to the release it leads up to, so v3.00b2 gives way to v3.00 the moment it is
|
||||||
|
put there. A build for another architecture, or a name that does not parse
|
||||||
|
at all, is still run when it is the only one there: a complaint from the
|
||||||
|
engine says more than "cannot find Singe". So an
|
||||||
|
upgrade is a matter of putting the new binary in the folder; the old one
|
||||||
|
can stay. The new --refresh sets the Singe folder aside as
|
||||||
|
old-Singe-<version>, named from the version.txt the folder now carries, and
|
||||||
|
writes a fresh one, so a file an older release shipped and this one does not
|
||||||
|
cannot linger. A second --refresh keeps its own as old-Singe-<version>-1.
|
||||||
|
Nothing is deleted anywhere in this: what is set aside is the owner's to
|
||||||
|
remove. For the same reason the menu's game update and Forge's publish
|
||||||
|
write a new file beside the old one and only release the old copy once the
|
||||||
|
new one is in place, so an interrupted write cannot lose either.
|
||||||
|
|
||||||
|
- --patch gives back the space a replaced file was using. SQLite keeps the
|
||||||
|
pages a deleted blob held for the next thing written, so a developer who
|
||||||
|
repacks a video a dozen times ends up with a .game several times the size
|
||||||
|
of its contents. A patch now measures the free space once its transaction
|
||||||
|
has committed and, when there is at least a megabyte of it, compacts the
|
||||||
|
database and reports what it recovered -- an 8 MB file whose video was
|
||||||
|
replaced by a 1 KB placeholder comes back to 28 KB, the size of a fresh
|
||||||
|
pack. Under a megabyte it is left alone, since compacting rewrites the
|
||||||
|
whole file.
|
||||||
|
|
||||||
- Forge has tutorials: ten sittings at the front of Forge.pdf, each
|
- Forge has tutorials: ten sittings at the front of Forge.pdf, each
|
||||||
ending with a game that plays, with the kit of pictures, sounds, a clip,
|
ending with a game that plays, with the kit of pictures, sounds, a clip,
|
||||||
and a model they use packed in Forge.game, and the finished description
|
and a model they use packed in Forge.game, and the finished description
|
||||||
|
|
@ -1219,7 +1302,26 @@ New Features
|
||||||
the overlay it is drawn on, so a larger recording gets a larger tagline
|
the overlay it is drawn on, so a larger recording gets a larger tagline
|
||||||
rather than the menu's scaled up.
|
rather than the menu's scaled up.
|
||||||
|
|
||||||
- The Singe logo is on the cover of all three books.
|
- The manual is two books. Everything about running Singe -- installing it,
|
||||||
|
installing games, the menu, controls, settings, the command line, bezels,
|
||||||
|
the game directory, and the questions people ask -- stays in Manual.pdf,
|
||||||
|
which is the one book the binary carries and unpacks. Everything about
|
||||||
|
making games -- the programming model, packing, games.dat, the GUI, 3D,
|
||||||
|
physics, particles, the formats, and every function in the Lua API -- is
|
||||||
|
the new Singe Reference, docs/Reference.adoc. The two link into each
|
||||||
|
other where they always did. ./build-docs.sh builds both, the Forge book,
|
||||||
|
and the beginner's course, and packs the three books that do not ship in
|
||||||
|
the binary, with the lesson files and the licence, into
|
||||||
|
Singe-Docs-v<version>.zip, an optional download beside the engine. The
|
||||||
|
HTML pages carry their pictures inside them, so each is one file wherever
|
||||||
|
it is copied.
|
||||||
|
|
||||||
|
- ./build-docs.sh preview builds the four books with PREVIEW laid diagonally
|
||||||
|
across every page, PDF and HTML alike, for proofreaders, and names the
|
||||||
|
documentation download -preview so it cannot pass for a release. The
|
||||||
|
plain build, and the manual the binary carries, never wear it.
|
||||||
|
|
||||||
|
- The Singe logo is on the cover of all four books.
|
||||||
|
|
||||||
- A third book: "Learn to Program with Singe", docs/Learn.adoc, built by
|
- A third book: "Learn to Program with Singe", docs/Learn.adoc, built by
|
||||||
./build-docs.sh beside the manual and the Forge book. Thirty lessons that
|
./build-docs.sh beside the manual and the Forge book. Thirty lessons that
|
||||||
|
|
@ -1380,6 +1482,21 @@ New Features
|
||||||
Fixes
|
Fixes
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
- Two things on the engine update's Windows path. Windows cannot replace a
|
||||||
|
running image, so the update starts the new Singe beside this one and lets
|
||||||
|
this one end; but with a console -- the default -- this one then sat in
|
||||||
|
getchar() waiting for a key, so two engines were alive at once and the old
|
||||||
|
one held the console the new one was writing to. The key wait now happens
|
||||||
|
only when nothing took over, and still happens when the start failed, since
|
||||||
|
that message is worth reading. And setting the running binary aside as
|
||||||
|
old-<name> ignored whether the rename worked. It usually does: renaming a
|
||||||
|
running executable is allowed on Windows as it is everywhere, as long as the
|
||||||
|
new name is on the same volume, and only deleting one is refused. It can
|
||||||
|
still be beaten by a folder the user cannot write or by a scanner holding the
|
||||||
|
file, and that was silent. The tool now says so, on screen and in the trace,
|
||||||
|
and carries on: the launcher runs the newest Singe in the folder, so the old
|
||||||
|
binary left in place is harmless.
|
||||||
|
|
||||||
- The menu's grid, horizon and sun were drawn black and unlit through the
|
- The menu's grid, horizon and sun were drawn black and unlit through the
|
||||||
intro, which kept the lights off them but not the blast's glow behind
|
intro, which kept the lights off them but not the blast's glow behind
|
||||||
them: a black line across a flash is a line, and the whole grid showed
|
them: a black line across a flash is a line, and the whole grid showed
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,28 @@
|
||||||
|
|
||||||
|
|
||||||
cmake_minimum_required(VERSION 3.22)
|
cmake_minimum_required(VERSION 3.22)
|
||||||
project(singe2 VERSION 3.00 LANGUAGES C CXX)
|
|
||||||
|
# The version, in CMake's four numbers, because that is all CMake's project() will take:
|
||||||
|
#
|
||||||
|
# major . minor . patch . tweak
|
||||||
|
# ^^^^^ ^^^^^ ^^^^^
|
||||||
|
# kept as written (00 stays 00)
|
||||||
|
# 0 = a release, 1 = a beta
|
||||||
|
# which beta
|
||||||
|
#
|
||||||
|
# so 3.00.0.0 is "v3.00" and 3.00.1.2 is "v3.00b2". One line to edit, no second variable, and
|
||||||
|
# the parts stay numbers, which is what the Windows resource needs anyway.
|
||||||
|
#
|
||||||
|
# A beta of 3.00 comes BEFORE 3.00, and the master service knows it: it reads the same shape out
|
||||||
|
# of a version string and ranks a beta below the release of the same number, so a cabinet on
|
||||||
|
# v3.00b2 is offered v3.00 when it arrives.
|
||||||
|
project(singe2 VERSION 3.00.1.1 LANGUAGES C CXX)
|
||||||
|
|
||||||
|
if(PROJECT_VERSION_PATCH EQUAL 1)
|
||||||
|
set(SINGE_VERSION_FULL "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}b${PROJECT_VERSION_TWEAK}")
|
||||||
|
else()
|
||||||
|
set(SINGE_VERSION_FULL "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
|
||||||
|
endif()
|
||||||
|
|
||||||
set(CMAKE_C_STANDARD 11)
|
set(CMAKE_C_STANDARD 11)
|
||||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
|
|
@ -253,7 +274,7 @@ set(MANUAL_DIR ${CMAKE_SOURCE_DIR}/.builddir)
|
||||||
add_custom_command(
|
add_custom_command(
|
||||||
OUTPUT ${MANUAL_DIR}/Manual.pdf
|
OUTPUT ${MANUAL_DIR}/Manual.pdf
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
|
||||||
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.pdf
|
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.pdf
|
||||||
DEPENDS docs/Manual.adoc
|
DEPENDS docs/Manual.adoc
|
||||||
COMMENT "Rendering Manual.pdf"
|
COMMENT "Rendering Manual.pdf"
|
||||||
VERBATIM
|
VERBATIM
|
||||||
|
|
@ -315,19 +336,22 @@ file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/im
|
||||||
add_custom_command(
|
add_custom_command(
|
||||||
OUTPUT ${MANUAL_DIR}/Forge.pdf
|
OUTPUT ${MANUAL_DIR}/Forge.pdf
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
|
||||||
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
|
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
|
||||||
DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc ${FORGE_BOOK_PARTS}
|
DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc ${FORGE_BOOK_PARTS}
|
||||||
COMMENT "Rendering Forge.pdf"
|
COMMENT "Rendering Forge.pdf"
|
||||||
VERBATIM
|
VERBATIM
|
||||||
)
|
)
|
||||||
add_custom_target(forgeManual ALL DEPENDS ${MANUAL_DIR}/Forge.pdf)
|
add_custom_target(forgeManual ALL DEPENDS ${MANUAL_DIR}/Forge.pdf)
|
||||||
|
|
||||||
# Optional HTML manual for browsing: cmake --build . --target docs
|
# Optional HTML for browsing: cmake --build . --target docs. The user manual is the one book the
|
||||||
|
# binary carries; the reference, the Forge book, and the beginner's course are the optional
|
||||||
|
# documentation download that build-docs.sh packs.
|
||||||
if(ASCIIDOCTOR)
|
if(ASCIIDOCTOR)
|
||||||
add_custom_target(docs
|
add_custom_target(docs
|
||||||
COMMAND ${ASCIIDOCTOR} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.html
|
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.html
|
||||||
|
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Reference.adoc -o ${MANUAL_DIR}/Reference.html
|
||||||
DEPENDS ${MANUAL_DIR}/Manual.pdf
|
DEPENDS ${MANUAL_DIR}/Manual.pdf
|
||||||
COMMENT "Rendering Manual.html"
|
COMMENT "Rendering Manual.html and Reference.html"
|
||||||
VERBATIM
|
VERBATIM
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
@ -596,7 +620,7 @@ endif()
|
||||||
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
|
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
|
||||||
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
|
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
|
||||||
string(TOUPPER ${osInitial} osInitial)
|
string(TOUPPER ${osInitial} osInitial)
|
||||||
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME "Singe-v${PROJECT_VERSION}-${osInitial}${osRest}-${KANGAROO_ARCH}")
|
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME "Singe-v${SINGE_VERSION_FULL}-${osInitial}${osRest}-${KANGAROO_ARCH}")
|
||||||
|
|
||||||
# Warnings apply to our code only; the vendored libraries are not ours to fix.
|
# Warnings apply to our code only; the vendored libraries are not ours to fix.
|
||||||
set_source_files_properties(${SINGE_SOURCE} PROPERTIES COMPILE_OPTIONS "-Wall;-Wextra")
|
set_source_files_properties(${SINGE_SOURCE} PROPERTIES COMPILE_OPTIONS "-Wall;-Wextra")
|
||||||
|
|
@ -606,6 +630,9 @@ set_source_files_properties(thirdparty/librs232/bindings/lua/luars232.c PROPERTI
|
||||||
set_source_files_properties(thirdparty/basis_universal/transcoder/basisu_transcoder.cpp PROPERTIES COMPILE_OPTIONS "-w")
|
set_source_files_properties(thirdparty/basis_universal/transcoder/basisu_transcoder.cpp PROPERTIES COMPILE_OPTIONS "-w")
|
||||||
|
|
||||||
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
|
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
|
||||||
|
# The platform part of the release name (Linux-x86_64), so a script can ask which release it
|
||||||
|
# is and fetch the right one.
|
||||||
|
SINGE_PLATFORM="${osInitial}${osRest}-${KANGAROO_ARCH}"
|
||||||
RS232_STATIC
|
RS232_STATIC
|
||||||
BASISD_SUPPORT_KTX2=1
|
BASISD_SUPPORT_KTX2=1
|
||||||
BASISD_SUPPORT_KTX2_ZSTD=1
|
BASISD_SUPPORT_KTX2_ZSTD=1
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ local NAV_RADIUS_2D = 0.08 -- Agents in a 2D room, in navigation units: eig
|
||||||
local NAV_HEIGHT_2D = 0.5
|
local NAV_HEIGHT_2D = 0.5
|
||||||
local WALK_TIMEOUT = 12 -- Seconds a walkTo waits before giving up on an unreachable point.
|
local WALK_TIMEOUT = 12 -- Seconds a walkTo waits before giving up on an unreachable point.
|
||||||
local FADE_DEFAULT = 0.6 -- Seconds a fade takes when the rule does not say.
|
local FADE_DEFAULT = 0.6 -- Seconds a fade takes when the rule does not say.
|
||||||
|
local PICTURE_BRIGHT = 127 -- A light sensor's thresholds: how near white on every channel
|
||||||
|
local PICTURE_DARK = 20 -- reads as bright, how near black reads as dark, between them unknown.
|
||||||
local CHOICE_KEYS = { "MAIN_1", "MAIN_2", "MAIN_3", "MAIN_4", "MAIN_5", "MAIN_6", "MAIN_7", "MAIN_8", "MAIN_9" }
|
local CHOICE_KEYS = { "MAIN_1", "MAIN_2", "MAIN_3", "MAIN_4", "MAIN_5", "MAIN_6", "MAIN_7", "MAIN_8", "MAIN_9" }
|
||||||
local dialogues = {} -- Compiled dialogues by name, from authorDialogues.
|
local dialogues = {} -- Compiled dialogues by name, from authorDialogues.
|
||||||
local talking = nil -- The dialogue on screen: { lines, choices, chosen }.
|
local talking = nil -- The dialogue on screen: { lines, choices, chosen }.
|
||||||
|
|
@ -1546,6 +1548,58 @@ AUTHOR.behaviours.target = {
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AUTHOR.behaviours.lightSensor = {
|
||||||
|
help = "Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead.",
|
||||||
|
params = { x = "number", y = "number", left = "number", top = "number", high = "number", low = "number", trigger = "switch", player = "number" },
|
||||||
|
defaults = { high = PICTURE_BRIGHT, low = PICTURE_DARK, trigger = "SWITCH_BUTTON3", player = 1 },
|
||||||
|
attach = function(instance, params)
|
||||||
|
instance.sensor = params
|
||||||
|
instance.hearsAll = true -- The trigger comes as a press about nothing in particular.
|
||||||
|
instance.trigger = authorSwitchValue(params.trigger)
|
||||||
|
instance.vars.player = params.player or 1
|
||||||
|
instance.pulled = 0
|
||||||
|
instance.spotWas = nil
|
||||||
|
instance.gunWas = nil
|
||||||
|
end,
|
||||||
|
step = function(instance)
|
||||||
|
local p = instance.sensor
|
||||||
|
local spotState = authorPictureState(p.x or 0, p.y or 0, p.high, p.low)
|
||||||
|
|
||||||
|
if (instance.pulled > 0) and (instance.spotWas ~= nil) and (spotState ~= instance.spotWas) then
|
||||||
|
-- The picture changed frame: a sample. A shot into the spot's corner is not judged.
|
||||||
|
local gx, gy = authorPointer(instance.vars.player)
|
||||||
|
local gunState = "unknown"
|
||||||
|
|
||||||
|
if (gx < (p.left or 0)) or (gy < (p.top or 0)) then
|
||||||
|
gunState = authorPictureState(gx, gy, p.high, p.low)
|
||||||
|
end
|
||||||
|
if instance.pulled == 1 then
|
||||||
|
-- The second sample decides, when both samples of both spots were readable and the
|
||||||
|
-- gun's changed between them: good when it changed the way the spot did.
|
||||||
|
if (gunState ~= "unknown") and (spotState ~= "unknown") and (instance.gunWas ~= "unknown") and (instance.spotWas ~= "unknown") and (instance.gunWas ~= gunState) then
|
||||||
|
if gunState == spotState then
|
||||||
|
authorEvent("hit", { self = instance, player = instance.vars.player, x = gx, y = gy })
|
||||||
|
else
|
||||||
|
authorEvent("miss", { self = instance, player = instance.vars.player, offscreen = false, x = gx, y = gy })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
instance.gunWas = gunState
|
||||||
|
end
|
||||||
|
instance.pulled = instance.pulled - 1
|
||||||
|
end
|
||||||
|
instance.spotWas = spotState
|
||||||
|
end,
|
||||||
|
on = function(instance, name, event)
|
||||||
|
-- The trigger: the shot is judged over the next two frames of the picture.
|
||||||
|
if (name == "pressed") and (event.switch == instance.trigger) then
|
||||||
|
instance.pulled = 2
|
||||||
|
instance.gunWas = nil
|
||||||
|
authorBehaviourEvent(instance, "fire")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
AUTHOR.behaviours.walker = {
|
AUTHOR.behaviours.walker = {
|
||||||
help = "Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes.",
|
help = "Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes.",
|
||||||
params = { speed = "number", radius = "number", follow = "string", every = "number", stopAt = "number" },
|
params = { speed = "number", radius = "number", follow = "string", every = "number", stopAt = "number" },
|
||||||
|
|
@ -2100,6 +2154,13 @@ AUTHOR.conditions.pointerOffscreen = {
|
||||||
emit = function(p) return "authorPointerOffscreen(" .. p.player .. ")" end
|
emit = function(p) return "authorPointerOffscreen(" .. p.player .. ")" end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AUTHOR.conditions.pictureAt = {
|
||||||
|
help = "The disc's picture at a point is bright, dark, or neither: a flash a game hides in its own video, a colour cue, or a lamp in the scene. A game with no disc reads black, since the engine answers zeroes when nothing is playing.",
|
||||||
|
params = { x = "number", y = "number", is = "string", high = "number", low = "number" },
|
||||||
|
defaults = { is = "bright", high = PICTURE_BRIGHT, low = PICTURE_DARK },
|
||||||
|
emit = function(p) return "authorPictureIs(" .. p.x .. ", " .. p.y .. ", " .. p.is .. ", " .. p.high .. ", " .. p.low .. ")" end
|
||||||
|
}
|
||||||
|
|
||||||
AUTHOR.conditions.onScreen = {
|
AUTHOR.conditions.onScreen = {
|
||||||
help = "An instance's box is at least partly on the overlay.",
|
help = "An instance's box is at least partly on the overlay.",
|
||||||
params = { entity = "entity" },
|
params = { entity = "entity" },
|
||||||
|
|
@ -4379,6 +4440,29 @@ function authorPointerOffscreen(player)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- What the picture is at a point, as a light sensor sees it: bright, dark, or unknown between
|
||||||
|
-- the two thresholds. The engine answers zeroes when no video is playing, so a game with no
|
||||||
|
-- disc layer reads dark everywhere rather than failing.
|
||||||
|
function authorPictureState(x, y, high, low)
|
||||||
|
local r, g, b = vldpGetPixel(math.floor(x), math.floor(y))
|
||||||
|
|
||||||
|
high = high or PICTURE_BRIGHT
|
||||||
|
low = low or PICTURE_DARK
|
||||||
|
if ((255 - r) < high) and ((255 - g) < high) and ((255 - b) < high) then
|
||||||
|
return "bright"
|
||||||
|
elseif (r < low) and (g < low) and (b < low) then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
function authorPictureIs(x, y, want, high, low)
|
||||||
|
return authorPictureState(x, y, high, low) == want
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- What a gun can hit an instance in: its track's box at this frame when it has a hitbox
|
-- What a gun can hit an instance in: its track's box at this frame when it has a hitbox
|
||||||
-- behaviour, its look's box otherwise. nil when the track has no box now.
|
-- behaviour, its look's box otherwise. nil when the track has no box now.
|
||||||
function authorHitShape(instance)
|
function authorHitShape(instance)
|
||||||
|
|
@ -5683,6 +5767,17 @@ function Q.setupLevel(q, thisLevel)
|
||||||
q.iPathAend = 0
|
q.iPathAend = 0
|
||||||
if q.swapScene then
|
if q.swapScene then
|
||||||
q.swapScene()
|
q.swapScene()
|
||||||
|
else
|
||||||
|
-- The same answers, read from the description: the converter asked the game's swapScene
|
||||||
|
-- for every difficulty, level and scene, and kept the ones that differ. A game that still
|
||||||
|
-- carries the hook runs it, since a hook can be edited to do more than this describes.
|
||||||
|
local byLevel = (q.snapshot.swaps or {})[q.dip_Difficulty or 0]
|
||||||
|
local scene = byLevel and byLevel[thisLevel] and byLevel[thisLevel][q.iScene]
|
||||||
|
|
||||||
|
if scene then
|
||||||
|
q.bSwap = true
|
||||||
|
q.iScene = scene
|
||||||
|
end
|
||||||
end
|
end
|
||||||
q.bFlip = false
|
q.bFlip = false
|
||||||
if q.iScene > level[QTE.TOTALSCENES] then
|
if q.iScene > level[QTE.TOTALSCENES] then
|
||||||
|
|
@ -8682,6 +8777,17 @@ function K.setupLevel(q, thisLevel)
|
||||||
q.iPath, q.iPathAjmp, q.iPathAend = 0, 0, 0
|
q.iPath, q.iPathAjmp, q.iPathAend = 0, 0, 0
|
||||||
if q.swapScene then
|
if q.swapScene then
|
||||||
q.swapScene()
|
q.swapScene()
|
||||||
|
else
|
||||||
|
-- The same answers, read from the description: the converter asked the game's swapScene
|
||||||
|
-- for every difficulty, level and scene, and kept the ones that differ. A game that still
|
||||||
|
-- carries the hook runs it, since a hook can be edited to do more than this describes.
|
||||||
|
local byLevel = (q.snapshot.swaps or {})[q.dip_Difficulty or 0]
|
||||||
|
local scene = byLevel and byLevel[thisLevel] and byLevel[thisLevel][q.iScene]
|
||||||
|
|
||||||
|
if scene then
|
||||||
|
q.bSwap = true
|
||||||
|
q.iScene = scene
|
||||||
|
end
|
||||||
end
|
end
|
||||||
q.bFlip = false
|
q.bFlip = false
|
||||||
if q.iScene > level[QTE.TOTALSCENES] then
|
if q.iScene > level[QTE.TOTALSCENES] then
|
||||||
|
|
|
||||||
|
|
@ -1214,6 +1214,7 @@ end
|
||||||
|
|
||||||
|
|
||||||
function forgeSave()
|
function forgeSave()
|
||||||
|
forgeIdFor(FORGE.game)
|
||||||
authorSave(FORGE.game, FORGE.path)
|
authorSave(FORGE.game, FORGE.path)
|
||||||
FORGE.dirty = false
|
FORGE.dirty = false
|
||||||
FORGE.closing = false
|
FORGE.closing = false
|
||||||
|
|
@ -1304,10 +1305,13 @@ function forgeExport(folder, name)
|
||||||
if not video:match("^Singe[/\\]") and not video:match("^[/\\]") and not video:match("^%a:") then
|
if not video:match("^Singe[/\\]") and not video:match("^[/\\]") and not video:match("^%a:") then
|
||||||
video = base .. "/" .. video
|
video = base .. "/" .. video
|
||||||
end
|
end
|
||||||
|
-- The id travels with the release: it is what the master service keys scores and updates
|
||||||
|
-- on, and a game without one cannot be published. Every description has one from the moment
|
||||||
|
-- it is first saved (forgeIdFor).
|
||||||
dat = io.open(folder .. "/games.dat", "w")
|
dat = io.open(folder .. "/games.dat", "w")
|
||||||
if dat ~= nil then
|
if dat ~= nil then
|
||||||
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
|
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tGAME_ID = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
|
||||||
title, base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
|
title, forgeIdFor(FORGE.game), base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
|
||||||
dat:close()
|
dat:close()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1318,6 +1322,94 @@ function forgeExport(folder, name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Moves a packed game into place, safely: what is there goes aside first, so a rename that fails
|
||||||
|
-- leaves the previous export where it was rather than a hole where it used to be. The old copy is
|
||||||
|
-- let go once the new one is in place -- pressing the key again rebuilds an export from the
|
||||||
|
-- description, so there is nothing here worth keeping a second copy of. The name it goes aside
|
||||||
|
-- under stops ending in ".game" on purpose, so the menu cannot list it as a second game.
|
||||||
|
function forgeReplaceFile(path, incoming)
|
||||||
|
local kept = nil
|
||||||
|
|
||||||
|
if lfs.attributes(path) then
|
||||||
|
local target = path .. ".old"
|
||||||
|
local n = 1
|
||||||
|
|
||||||
|
while lfs.attributes(target) and (n < 100) do
|
||||||
|
n = n + 1
|
||||||
|
target = path .. ".old-" .. n
|
||||||
|
end
|
||||||
|
if os.rename(path, target) then
|
||||||
|
kept = target
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not os.rename(incoming, path) then
|
||||||
|
if kept then
|
||||||
|
os.rename(kept, path)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if kept then
|
||||||
|
os.remove(kept)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Publish: export, pack, and send the package to the master service for review, in one press.
|
||||||
|
-- The service keeps it hidden until the operator approves it, and says which version it was
|
||||||
|
-- given; a later press sends a later version, which is reviewed on its own. The account is the
|
||||||
|
-- machine's, signed in through the menu's Online Account tool, so Forge signs nothing in itself.
|
||||||
|
function forgePublish()
|
||||||
|
if forgePublishing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if masterSignedIn == nil then
|
||||||
|
dofile("Singe/Net.singe")
|
||||||
|
dofile("Singe/Master.singe")
|
||||||
|
masterLoad()
|
||||||
|
end
|
||||||
|
if not masterSignedIn() then
|
||||||
|
FORGE.message = "sign in first, in the menu's Online Account tool, then press P again"
|
||||||
|
forgeRefresh()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local folder = forgeExport(forgeReleaseFolder())
|
||||||
|
if not folder then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- A file named .game is a container to the engine's own file lookup, and a script cannot read
|
||||||
|
-- one as a file; so the package is written under a working name, sent, and only then renamed
|
||||||
|
-- to what it is.
|
||||||
|
local file = folder .. ".game"
|
||||||
|
local working = file .. ".part"
|
||||||
|
if not singePack(folder, working) then
|
||||||
|
FORGE.message = "could not pack " .. folder
|
||||||
|
forgeRefresh()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
forgePublishing = true
|
||||||
|
FORGE.message = "uploading " .. file
|
||||||
|
forgeRefresh()
|
||||||
|
masterUpload(working, nil,
|
||||||
|
function(sent, total)
|
||||||
|
FORGE.message = string.format("uploading: %d%%", total and math.floor(sent * 100 / total) or 0)
|
||||||
|
forgeRefresh()
|
||||||
|
end,
|
||||||
|
function(ok, reply)
|
||||||
|
forgePublishing = false
|
||||||
|
-- The package that was just sent takes its proper name, put there safely so a failure
|
||||||
|
-- leaves the previous export intact.
|
||||||
|
forgeReplaceFile(file, working)
|
||||||
|
if ok then
|
||||||
|
FORGE.message = string.format("sent for review as version %d; the service will list it once the operator approves it", reply.version or 0)
|
||||||
|
else
|
||||||
|
FORGE.message = "not sent: " .. tostring(reply)
|
||||||
|
end
|
||||||
|
forgeRefresh()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- Where X releases to: a folder in the data directory named for the game, which is also what
|
-- Where X releases to: a folder in the data directory named for the game, which is also what
|
||||||
-- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the
|
-- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the
|
||||||
-- folder and its name.
|
-- folder and its name.
|
||||||
|
|
@ -4070,6 +4162,8 @@ function forgeKey(keysym, scancode)
|
||||||
forgeBuild(singeGetDataPath() .. "preview.singe")
|
forgeBuild(singeGetDataPath() .. "preview.singe")
|
||||||
elseif scancode == SCANCODE.X.value then
|
elseif scancode == SCANCODE.X.value then
|
||||||
forgeExport(forgeReleaseFolder())
|
forgeExport(forgeReleaseFolder())
|
||||||
|
elseif scancode == SCANCODE.P.value then
|
||||||
|
forgePublish()
|
||||||
elseif scancode == SCANCODE.U.value then
|
elseif scancode == SCANCODE.U.value then
|
||||||
forgeUndo()
|
forgeUndo()
|
||||||
elseif scancode == SCANCODE.R.value then
|
elseif scancode == SCANCODE.R.value then
|
||||||
|
|
@ -4675,10 +4769,36 @@ end
|
||||||
-- Forge can write. Ones dropped into Forge's own directory are offered too, and opened as a copy
|
-- Forge can write. Ones dropped into Forge's own directory are offered too, and opened as a copy
|
||||||
-- in the data directory, since inside a .game they are read only.
|
-- in the data directory, since inside a .game they are read only.
|
||||||
|
|
||||||
|
-- A game's id: the UUID the master service keys scores and updates on. Minted the first time a
|
||||||
|
-- description is saved and never changed after, so a game uploaded once can be updated for ever;
|
||||||
|
-- a description written by hand without one gets one the first time Forge saves it. Version 4,
|
||||||
|
-- from the engine's generator, which is as random as a game id needs to be.
|
||||||
|
function forgeIdFor(game)
|
||||||
|
if type(game.id) == "string" and game.id:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") then
|
||||||
|
return game.id
|
||||||
|
end
|
||||||
|
math.randomseed(os.time() + math.floor(os.clock() * 1000000))
|
||||||
|
local hex = {}
|
||||||
|
for index = 1, 32 do
|
||||||
|
local digit = math.random(0, 15)
|
||||||
|
if index == 13 then
|
||||||
|
digit = 4 -- the version nibble
|
||||||
|
elseif index == 17 then
|
||||||
|
digit = 8 + math.random(0, 3) -- the variant nibble: 8, 9, a, or b
|
||||||
|
end
|
||||||
|
hex[index] = string.format("%x", digit)
|
||||||
|
end
|
||||||
|
local text = table.concat(hex)
|
||||||
|
game.id = string.format("%s-%s-%s-%s-%s", text:sub(1, 8), text:sub(9, 12), text:sub(13, 16), text:sub(17, 20), text:sub(21, 32))
|
||||||
|
return game.id
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play
|
-- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play
|
||||||
-- on, which is the point; everything in it can be changed or deleted.
|
-- on, which is the point; everything in it can be changed or deleted.
|
||||||
function forgeTemplate()
|
function forgeTemplate()
|
||||||
return {
|
return {
|
||||||
|
id = forgeIdFor({}),
|
||||||
title = "New game",
|
title = "New game",
|
||||||
layers = { { kind = "world2d", gravity = 1500 } },
|
layers = { { kind = "world2d", gravity = 1500 } },
|
||||||
vars = { score = 0 },
|
vars = { score = 0 },
|
||||||
|
|
@ -4944,6 +5064,10 @@ if not FORGE_LIBRARY then
|
||||||
|
|
||||||
|
|
||||||
function onOverlayUpdate()
|
function onOverlayUpdate()
|
||||||
|
-- The master service's client, once Publish has loaded it, needs a turn every frame.
|
||||||
|
if masterPump ~= nil then
|
||||||
|
masterPump()
|
||||||
|
end
|
||||||
if FORGE.game == nil then
|
if FORGE.game == nil then
|
||||||
forgeChooserDraw()
|
forgeChooserDraw()
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,12 @@ local function request(method, endpoint, body, onDone, opts)
|
||||||
if MASTER.token and not opts.anonymous then
|
if MASTER.token and not opts.anonymous then
|
||||||
headers["Authorization"] = "Bearer " .. MASTER.token
|
headers["Authorization"] = "Bearer " .. MASTER.token
|
||||||
end
|
end
|
||||||
|
if opts.bodyFile then
|
||||||
|
headers["Content-Type"] = opts.contentType or "application/octet-stream"
|
||||||
|
end
|
||||||
netRequest({
|
netRequest({
|
||||||
url = MASTER.url .. endpoint, method = method, headers = headers,
|
url = MASTER.url .. endpoint, method = method, headers = headers,
|
||||||
body = body and cjson.encode(body) or nil,
|
body = body and cjson.encode(body) or nil, bodyFile = opts.bodyFile,
|
||||||
toFile = opts.toFile, onProgress = opts.onProgress,
|
toFile = opts.toFile, onProgress = opts.onProgress,
|
||||||
}, function(ok, result)
|
}, function(ok, result)
|
||||||
if not ok then
|
if not ok then
|
||||||
|
|
@ -247,9 +250,84 @@ function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
function masterTellInstalled(gameId, version, onDone)
|
-- What this machine holds, and what is newer. The list is the menu's own, read off every
|
||||||
request("POST", "/v1/installed", { game = gameId, version = version and math.floor(version) or nil },
|
-- games.dat it loaded (GAME_ID and VERSION), and the service keeps none of it: it answers which
|
||||||
onDone or function() end)
|
-- of these have a newer version and which it has never heard of. No sign-in needed to ask.
|
||||||
|
function masterVersions(held, onDone)
|
||||||
|
local games = {}
|
||||||
|
|
||||||
|
for _, entry in ipairs(held) do
|
||||||
|
games[#games + 1] = { id = entry.id, version = math.floor(entry.version or 0) }
|
||||||
|
end
|
||||||
|
request("POST", "/v1/versions", { games = games }, function(ok, reply)
|
||||||
|
if ok then
|
||||||
|
for _, update in ipairs(reply.updates or {}) do
|
||||||
|
update.installed = update.installed and math.floor(update.installed) or 0
|
||||||
|
update.latest = update.latest and math.floor(update.latest) or nil
|
||||||
|
update.size = update.size and math.floor(update.size) or nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
onDone(ok, ok and (reply.updates or {}) or reply, ok and (reply.unknown or {}) or nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Send a packed game up for review. The file goes as the body, from disk, so a big game is not
|
||||||
|
-- held in memory; onProgress(sent, total) says how it goes, and onDone(ok, reply) ends with the
|
||||||
|
-- version number the service gave it and the state it starts in, which is "pending".
|
||||||
|
function masterUpload(file, notes, onProgress, onDone)
|
||||||
|
local endpoint = "/v1/upload"
|
||||||
|
|
||||||
|
if notes and notes ~= "" then
|
||||||
|
endpoint = endpoint .. "?notes=" .. notes:gsub("[^%w%-%._~]", function(c)
|
||||||
|
return string.format("%%%02X", c:byte())
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
request("POST", endpoint, nil, function(ok, reply)
|
||||||
|
if ok and reply.version then
|
||||||
|
reply.version = math.floor(reply.version)
|
||||||
|
end
|
||||||
|
onDone(ok, reply)
|
||||||
|
end, { bodyFile = file, onProgress = onProgress })
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Is there a newer Singe for this machine? Public, no sign-in: the answer is the newest release
|
||||||
|
-- for the platform when it is newer than the version running, or newer = false.
|
||||||
|
function masterRelease(platform, running, onDone)
|
||||||
|
request("GET", "/v1/release/" .. platform .. "?version=" .. tostring(running), nil, function(ok, reply)
|
||||||
|
onDone(ok, reply)
|
||||||
|
end, { anonymous = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Fetch one release to a file, checked against the digest the service published, exactly as a
|
||||||
|
-- game is; a binary that arrives wrong must not be put beside the one that works.
|
||||||
|
function masterDownloadRelease(platform, version, toFile, expectSha, onProgress, onDone)
|
||||||
|
request("GET", "/v1/release/" .. platform .. "/" .. version, nil, function(ok, reply, _, result)
|
||||||
|
if not ok then
|
||||||
|
return onDone(false, reply)
|
||||||
|
end
|
||||||
|
local file = io.open(toFile, "rb")
|
||||||
|
if not file then
|
||||||
|
return onDone(false, "the download did not land on disk")
|
||||||
|
end
|
||||||
|
local bytes = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
if expectSha and utilSha256(bytes) ~= expectSha then
|
||||||
|
os.remove(toFile)
|
||||||
|
return onDone(false, "the download did not match its published checksum")
|
||||||
|
end
|
||||||
|
onDone(true, { bytes = #bytes, file = toFile })
|
||||||
|
end, { toFile = toFile, onProgress = onProgress, anonymous = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- This account's own games, every version with the review's word on it.
|
||||||
|
function masterMine(onDone)
|
||||||
|
request("GET", "/v1/catalogue/mine", nil, function(ok, reply)
|
||||||
|
onDone(ok, ok and (reply.games or {}) or reply)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<div id="developer" class="line"></div>
|
<div id="developer" class="line"></div>
|
||||||
<div id="publisher" class="line"></div>
|
<div id="publisher" class="line"></div>
|
||||||
<div id="port" class="line"></div>
|
<div id="port" class="line"></div>
|
||||||
|
<div id="notice" class="line"></div>
|
||||||
<div id="description"></div>
|
<div id="description"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="footer">
|
<div id="footer">
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ function loadGameAssets()
|
||||||
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
|
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
|
||||||
end
|
end
|
||||||
MENU_RENDER.showGame(game)
|
MENU_RENDER.showGame(game)
|
||||||
|
menuRefreshNotice()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -186,6 +187,8 @@ function onInputPressed(what)
|
||||||
else
|
else
|
||||||
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
|
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
|
||||||
end
|
end
|
||||||
|
elseif what == SWITCH_BUTTON3 and menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
|
||||||
|
menuUpdateGame()
|
||||||
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
|
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
|
||||||
gameStart()
|
gameStart()
|
||||||
end
|
end
|
||||||
|
|
@ -337,6 +340,146 @@ for dir in lfs.dir(".") do
|
||||||
end
|
end
|
||||||
table.sort(GAME_LIST, compareTitles)
|
table.sort(GAME_LIST, compareTitles)
|
||||||
|
|
||||||
|
-- What this machine holds, by the id and version each games.dat carries. VERSION is stamped
|
||||||
|
-- into a packed game by the service when it is published; a game without one is version 0.
|
||||||
|
-- This table is the only record of installed versions anywhere: the service keeps none.
|
||||||
|
MENU_VERSIONS = {}
|
||||||
|
MENU_UPDATES = {}
|
||||||
|
for _, game in ipairs(GAME_LIST) do
|
||||||
|
if game.GAME_ID then
|
||||||
|
MENU_VERSIONS[game.GAME_ID] = math.floor(tonumber(game.VERSION) or 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Asks the service which of the games here have a newer version. No sign-in is needed to ask;
|
||||||
|
-- the answer arrives whenever it arrives and the renderers pick it up. Called once at startup
|
||||||
|
-- and again whenever the Get Games tool opens.
|
||||||
|
function menuCheckVersions()
|
||||||
|
local held = {}
|
||||||
|
|
||||||
|
for id, version in pairs(MENU_VERSIONS) do
|
||||||
|
held[#held + 1] = { id = id, version = version }
|
||||||
|
end
|
||||||
|
if #held == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
masterVersions(held, function(ok, updates)
|
||||||
|
if not ok then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
MENU_UPDATES = {}
|
||||||
|
for _, update in ipairs(updates) do
|
||||||
|
MENU_UPDATES[update.id] = update
|
||||||
|
end
|
||||||
|
if MENU_RENDER.refreshList then
|
||||||
|
MENU_RENDER.refreshList()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The newer version the service offers for a game, or nil.
|
||||||
|
function menuUpdateFor(game)
|
||||||
|
return game.GAME_ID and MENU_UPDATES[game.GAME_ID] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Fetches the newer version of the selected game into place, saying how it goes where the
|
||||||
|
-- renderers show a notice. The download needs a sign-in, which the Online Account tool does.
|
||||||
|
function menuUpdateGame()
|
||||||
|
local game = GAME_LIST[GAME_SELECTED]
|
||||||
|
local update = menuUpdateFor(game)
|
||||||
|
|
||||||
|
if not update then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not masterSignedIn() then
|
||||||
|
menuNotice("Sign in first, in the Online Account tool (SERVICE), to fetch the update.")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if MENU_UPDATING then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
MENU_UPDATING = true
|
||||||
|
shopInstall({ id = update.id, slug = update.slug, title = update.title or game.TITLE,
|
||||||
|
version = update.latest, sha256 = update.sha256, target = game.CONTAINER },
|
||||||
|
function(text)
|
||||||
|
menuNotice("Updating: " .. text)
|
||||||
|
end,
|
||||||
|
function(ok, text)
|
||||||
|
MENU_UPDATING = false
|
||||||
|
-- The list first, since refreshing it resets the notice to whatever the selection
|
||||||
|
-- warrants; then what happened, which stays until the selection moves.
|
||||||
|
if MENU_RENDER.refreshList then
|
||||||
|
MENU_RENDER.refreshList()
|
||||||
|
end
|
||||||
|
menuNotice(ok and (text .. " Version " .. update.latest .. " plays from now on.") or text)
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- One line the renderers show under the game, for whatever the menu has to say.
|
||||||
|
MENU_NOTICE = nil
|
||||||
|
function menuNotice(text)
|
||||||
|
MENU_NOTICE = text
|
||||||
|
if MENU_RENDER.notice then
|
||||||
|
MENU_RENDER.notice(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The notice for the selected game when nothing is in flight: the update on offer, or, failing
|
||||||
|
-- that, the newer Singe on offer, or nothing. A download in progress keeps its own line.
|
||||||
|
function menuRefreshNotice()
|
||||||
|
if MENU_UPDATING then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local update = GAME_SELECTED and menuUpdateFor(GAME_LIST[GAME_SELECTED]) or nil
|
||||||
|
|
||||||
|
if update then
|
||||||
|
menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest))
|
||||||
|
elseif MENU_RELEASE then
|
||||||
|
menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version))
|
||||||
|
else
|
||||||
|
menuNotice(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Once a day, unless the settings say never: is there a newer Singe for this machine? The
|
||||||
|
-- answer is only a line on screen and a service tool; nothing is fetched or changed until a
|
||||||
|
-- person opens that tool and says so. The last look is remembered in the save store.
|
||||||
|
MENU_RELEASE = nil
|
||||||
|
local RELEASE_CHECK_EVERY = 24 * 60 * 60
|
||||||
|
|
||||||
|
function menuCheckRelease(force)
|
||||||
|
if not force and SINGE_UPDATE_CHECK == false then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local info = singeGetSystemInfo()
|
||||||
|
local last = tonumber(saveGet("releaseCheckedAt", 0)) or 0
|
||||||
|
|
||||||
|
if not force and (os.time() - last) < RELEASE_CHECK_EVERY then
|
||||||
|
MENU_RELEASE = saveGet("releaseOffered", nil)
|
||||||
|
if type(MENU_RELEASE) ~= "table" then
|
||||||
|
MENU_RELEASE = nil
|
||||||
|
end
|
||||||
|
menuRefreshNotice()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
masterRelease(info.platform, info.version, function(ok, reply)
|
||||||
|
if not ok then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
saveSet("releaseCheckedAt", os.time())
|
||||||
|
MENU_RELEASE = reply.newer and reply or nil
|
||||||
|
saveSet("releaseOffered", MENU_RELEASE or false)
|
||||||
|
menuRefreshNotice()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
if GAME_COUNT == 0 then
|
if GAME_COUNT == 0 then
|
||||||
debugPrint("No games found! Exiting.")
|
debugPrint("No games found! Exiting.")
|
||||||
singeQuit()
|
singeQuit()
|
||||||
|
|
@ -391,6 +534,8 @@ else
|
||||||
dofile("Singe/MenuOverlay.singe")
|
dofile("Singe/MenuOverlay.singe")
|
||||||
end
|
end
|
||||||
MENU_RENDER.begin()
|
MENU_RENDER.begin()
|
||||||
|
menuCheckVersions()
|
||||||
|
menuCheckRelease(false)
|
||||||
MENU_RENDER.layout()
|
MENU_RENDER.layout()
|
||||||
MENU_RENDER.buildList()
|
MENU_RENDER.buildList()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,9 @@ MENU_RENDER.buildList = function()
|
||||||
local rows = {}
|
local rows = {}
|
||||||
|
|
||||||
for index, game in ipairs(GAME_LIST) do
|
for index, game in ipairs(GAME_LIST) do
|
||||||
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. '</div>'
|
local badge = menuUpdateFor(game) and '<span class="badge">update</span>' or ""
|
||||||
|
|
||||||
|
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. badge .. '</div>'
|
||||||
end
|
end
|
||||||
menuElement("list").inner_rml = table.concat(rows)
|
menuElement("list").inner_rml = table.concat(rows)
|
||||||
for index = 1, GAME_COUNT do
|
for index = 1, GAME_COUNT do
|
||||||
|
|
@ -269,6 +271,21 @@ MENU_RENDER.buildList = function()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The service answered about versions, or an update landed: the badges and the notice change.
|
||||||
|
MENU_RENDER.refreshList = function()
|
||||||
|
MENU_RENDER.buildList()
|
||||||
|
if GAME_SELECTED then
|
||||||
|
menuRow(GAME_SELECTED):SetClass("selected", true)
|
||||||
|
menuRefreshNotice()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
MENU_RENDER.notice = function(text)
|
||||||
|
guiSetValue(GUI, DOCUMENT, "notice", menuEscape(text or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
MENU_RENDER.showGame = function(game)
|
MENU_RENDER.showGame = function(game)
|
||||||
menuRow(GAME_SELECTED):SetClass("selected", true)
|
menuRow(GAME_SELECTED):SetClass("selected", true)
|
||||||
scrollPending = true
|
scrollPending = true
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ local spriteCabinet = nil
|
||||||
local spriteMarquee = nil
|
local spriteMarquee = nil
|
||||||
local textSprites = {}
|
local textSprites = {}
|
||||||
local textShadows = {}
|
local textShadows = {}
|
||||||
|
local noticeText = nil -- One line under the details: an update on offer, or how a fetch is going.
|
||||||
local textFirst = 0 -- The first line of the details on show, which walks by itself.
|
local textFirst = 0 -- The first line of the details on show, which walks by itself.
|
||||||
local textDown = true -- and which way it is walking.
|
local textDown = true -- and which way it is walking.
|
||||||
local textClock = 0
|
local textClock = 0
|
||||||
|
|
@ -397,6 +398,18 @@ MENU_RENDER.buildList = function()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The service answered about versions, or an update landed. The badge is read every frame, so
|
||||||
|
-- only the notice has to be brought up to date.
|
||||||
|
MENU_RENDER.refreshList = function()
|
||||||
|
menuRefreshNotice()
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
MENU_RENDER.notice = function(text)
|
||||||
|
noticeText = text
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
MENU_RENDER.showGame = function(game)
|
MENU_RENDER.showGame = function(game)
|
||||||
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
|
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
|
||||||
spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE))
|
spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE))
|
||||||
|
|
@ -508,6 +521,9 @@ MENU_RENDER.frame = function()
|
||||||
-- Which game this is, since there is no list to look at. It heads the details rather than
|
-- 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.
|
-- 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)
|
local title = string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT)
|
||||||
|
if menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
|
||||||
|
title = title .. " [update]"
|
||||||
|
end
|
||||||
|
|
||||||
colorForeground(0, 0, 0, 255)
|
colorForeground(0, 0, 0, 255)
|
||||||
fontPrint(textX + SHADOW, textY + SHADOW, title)
|
fontPrint(textX + SHADOW, textY + SHADOW, title)
|
||||||
|
|
@ -552,6 +568,12 @@ MENU_RENDER.frame = function()
|
||||||
-- there was nothing at all, so the only way to learn that up and down move one game and left
|
-- 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.
|
-- and right move a page was to be told.
|
||||||
fontSelect(hintFont)
|
fontSelect(hintFont)
|
||||||
|
if noticeText and noticeText ~= "" then
|
||||||
|
colorForeground(0, 0, 0, 255)
|
||||||
|
fontPrint(hintsX + SHADOW, hintsY - LINE_HEIGHT + SHADOW, noticeText)
|
||||||
|
colorForeground(255, 211, 90, 255)
|
||||||
|
fontPrint(hintsX, hintsY - LINE_HEIGHT, noticeText)
|
||||||
|
end
|
||||||
colorForeground(0, 0, 0, 255)
|
colorForeground(0, 0, 0, 255)
|
||||||
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
|
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
|
||||||
colorForeground(178, 196, 224, 255)
|
colorForeground(178, 196, 224, 255)
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,9 @@ local function buildRequest(request)
|
||||||
for name, value in pairs(request.headers or {}) do
|
for name, value in pairs(request.headers or {}) do
|
||||||
lines[#lines + 1] = name .. ": " .. value
|
lines[#lines + 1] = name .. ": " .. value
|
||||||
end
|
end
|
||||||
if request.body then
|
if request.bodyFile then
|
||||||
|
lines[#lines + 1] = "Content-Length: " .. request.bodySize
|
||||||
|
elseif request.body then
|
||||||
lines[#lines + 1] = "Content-Length: " .. #request.body
|
lines[#lines + 1] = "Content-Length: " .. #request.body
|
||||||
end
|
end
|
||||||
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
|
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
|
||||||
|
|
@ -221,10 +223,16 @@ local function pumpHandshake(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The head goes first as one string. A body from a file follows it a piece at a time, read as
|
||||||
|
-- it is sent, so a game is never held in memory whole: the same reason a download goes straight
|
||||||
|
-- to disk.
|
||||||
|
local NET_SEND_CHUNK = 65536
|
||||||
|
|
||||||
local function pumpSending(request)
|
local function pumpSending(request)
|
||||||
if not request.outgoing then
|
if not request.outgoing then
|
||||||
request.outgoing = buildRequest(request)
|
request.outgoing = buildRequest(request)
|
||||||
end
|
end
|
||||||
|
if request.sent < #request.outgoing then
|
||||||
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
|
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
|
||||||
|
|
||||||
if sent then
|
if sent then
|
||||||
|
|
@ -234,8 +242,53 @@ local function pumpSending(request)
|
||||||
else
|
else
|
||||||
return fail(request, "send failed: " .. tostring(err))
|
return fail(request, "send failed: " .. tostring(err))
|
||||||
end
|
end
|
||||||
if request.sent >= #request.outgoing then
|
if request.sent < #request.outgoing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
request.lastProgress = singeGetTicks()
|
||||||
|
end
|
||||||
|
if not request.bodyFile then
|
||||||
request.state = "reading"
|
request.state = "reading"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- The body, from the file.
|
||||||
|
if not request.bodyHandle then
|
||||||
|
request.bodyHandle = io.open(request.bodyFile, "rb")
|
||||||
|
request.bodySent = 0
|
||||||
|
request.chunk = nil
|
||||||
|
request.chunkSent = 0
|
||||||
|
if not request.bodyHandle then
|
||||||
|
return fail(request, "cannot read " .. request.bodyFile)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not request.chunk then
|
||||||
|
request.chunk = request.bodyHandle:read(NET_SEND_CHUNK)
|
||||||
|
request.chunkSent = 0
|
||||||
|
if not request.chunk then
|
||||||
|
request.bodyHandle:close()
|
||||||
|
request.bodyHandle = nil
|
||||||
|
request.state = "reading"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local sent, err, partial = request.socket:send(request.chunk, request.chunkSent + 1)
|
||||||
|
|
||||||
|
if sent then
|
||||||
|
request.chunkSent = sent
|
||||||
|
elseif err == "timeout" then
|
||||||
|
request.chunkSent = partial or request.chunkSent
|
||||||
|
else
|
||||||
|
return fail(request, "send failed: " .. tostring(err))
|
||||||
|
end
|
||||||
|
if request.chunkSent > 0 then
|
||||||
|
request.lastProgress = singeGetTicks()
|
||||||
|
end
|
||||||
|
if request.chunkSent >= #request.chunk then
|
||||||
|
request.bodySent = request.bodySent + #request.chunk
|
||||||
|
request.chunk = nil
|
||||||
|
if request.onProgress then
|
||||||
|
request.onProgress(request.bodySent, request.bodySize)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -442,9 +495,19 @@ function netRequest(opts, onDone)
|
||||||
local request = {
|
local request = {
|
||||||
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
|
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
|
||||||
method = opts.method or "GET", headers = opts.headers, body = opts.body,
|
method = opts.method or "GET", headers = opts.headers, body = opts.body,
|
||||||
toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
|
bodyFile = opts.bodyFile, toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
|
||||||
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(),
|
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(), sent = 0,
|
||||||
}
|
}
|
||||||
|
-- A body sent from a file: its length has to be known before the head goes out.
|
||||||
|
if request.bodyFile then
|
||||||
|
local handle = io.open(request.bodyFile, "rb")
|
||||||
|
if not handle then
|
||||||
|
onDone(false, { error = "cannot read " .. tostring(request.bodyFile) })
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
request.bodySize = handle:seek("end")
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
nextId = nextId + 1
|
nextId = nextId + 1
|
||||||
active[request.id] = request
|
active[request.id] = request
|
||||||
beginConnect(request)
|
beginConnect(request)
|
||||||
|
|
|
||||||
|
|
@ -1026,6 +1026,162 @@ TOOLS[#TOOLS + 1] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
-- ===== 10b. Engine update =====
|
||||||
|
|
||||||
|
-- A newer Singe, when the service has one for this platform: what is running, what is offered,
|
||||||
|
-- and one button that fetches it, checks it against the published digest, puts it beside the
|
||||||
|
-- running one under the release's own name, sets the old one aside as old-<name> so the launcher
|
||||||
|
-- cannot pick it, and starts the new one. Nothing happens until Button 1; the daily check in
|
||||||
|
-- the menu only puts a line on screen. The engine's own support files are not touched: the new
|
||||||
|
-- binary unpacks its own on its first run, as it does today.
|
||||||
|
local updateSaying = ""
|
||||||
|
local updateBusy = false
|
||||||
|
|
||||||
|
|
||||||
|
local function updateShow()
|
||||||
|
local info = singeGetSystemInfo()
|
||||||
|
local rows = {}
|
||||||
|
|
||||||
|
rows[#rows + 1] = toolsPair("Running", info.version .. " (" .. tostring(info.platform) .. ")")
|
||||||
|
rows[#rows + 1] = toolsPair("Executable", info.executable or "?")
|
||||||
|
if MENU_RELEASE then
|
||||||
|
rows[#rows + 1] = toolsPair("Offered", MENU_RELEASE.version .. " (" .. tostring(MENU_RELEASE.size) .. " bytes)")
|
||||||
|
if MENU_RELEASE.notes and MENU_RELEASE.notes ~= "" then
|
||||||
|
rows[#rows + 1] = toolsSub(MENU_RELEASE.notes)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
rows[#rows + 1] = toolsPair("Offered", "nothing newer, as of the last look")
|
||||||
|
end
|
||||||
|
if SINGE_UPDATE_CHECK == false then
|
||||||
|
rows[#rows + 1] = toolsSub("The daily look is off (no_update_check in settings.cfg); Button 3 looks now.")
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = toolsText(updateSaying)
|
||||||
|
if MENU_RELEASE and not updateBusy then
|
||||||
|
rows[#rows + 1] = toolsKeys("Button 1: download and restart into " .. MENU_RELEASE.version .. " Button 3: look again Button 2: back")
|
||||||
|
else
|
||||||
|
rows[#rows + 1] = toolsKeys("Button 3: look again Button 2: back")
|
||||||
|
end
|
||||||
|
toolsShow(rows)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function updateFetch()
|
||||||
|
local info = singeGetSystemInfo()
|
||||||
|
local offered = MENU_RELEASE
|
||||||
|
|
||||||
|
if not offered or updateBusy or not info.executable then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local folder = info.executable:match("^(.*[/\\])") or ""
|
||||||
|
local newPath = folder .. offered.name
|
||||||
|
local partial = singeGetDataPath() .. offered.name .. ".part"
|
||||||
|
|
||||||
|
updateBusy = true
|
||||||
|
updateSaying = "Starting..."
|
||||||
|
updateShow()
|
||||||
|
masterDownloadRelease(info.platform, offered.version, partial, offered.sha256,
|
||||||
|
function(got, total)
|
||||||
|
updateSaying = total and string.format("Fetching: %d%% (%d of %d bytes)", math.floor(got * 100 / total), got, total)
|
||||||
|
or string.format("Fetching: %d bytes", got)
|
||||||
|
updateShow()
|
||||||
|
end,
|
||||||
|
function(ok, result)
|
||||||
|
updateBusy = false
|
||||||
|
if not ok then
|
||||||
|
updateSaying = "Sorry: " .. tostring(result)
|
||||||
|
updateShow()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Beside the running binary, under its own name. Only now, with the digest checked.
|
||||||
|
-- Anything already there is a leftover from an earlier try at the same version.
|
||||||
|
if not replaceFile(newPath, partial) then
|
||||||
|
-- A data directory on another disk: copy instead.
|
||||||
|
local from, to = io.open(partial, "rb"), io.open(newPath, "wb")
|
||||||
|
if from and to then
|
||||||
|
to:write(from:read("a"))
|
||||||
|
from:close()
|
||||||
|
to:close()
|
||||||
|
os.remove(partial)
|
||||||
|
else
|
||||||
|
updateSaying = "Downloaded, but could not put it beside the running Singe."
|
||||||
|
updateShow()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- The one running is set aside under a name the launchers' Singe* glob cannot match,
|
||||||
|
-- so a start by hand after this can only find the new one. Renaming a running
|
||||||
|
-- executable is allowed everywhere, Windows included, as long as the new name is on
|
||||||
|
-- the same volume; deleting one is refused there, and it is not deleted here.
|
||||||
|
--
|
||||||
|
-- The rename can still be refused: a folder this user cannot write, or a scanner
|
||||||
|
-- holding the file open on Windows. That is survivable, since the launcher runs the
|
||||||
|
-- highest version it finds and the new one is higher, but it is not something to
|
||||||
|
-- swallow -- somebody looking at the folder later deserves to know why the old binary
|
||||||
|
-- is still in it.
|
||||||
|
local oldName = info.executable:match("([^/\\]+)$") or "Singe"
|
||||||
|
local aside = folder .. "old-" .. oldName
|
||||||
|
local moved, why = os.rename(info.executable, aside)
|
||||||
|
|
||||||
|
if moved then
|
||||||
|
updateSaying = "Restarting into " .. offered.version .. "..."
|
||||||
|
else
|
||||||
|
debugPrint(string.format("Engine Update: %s could not be set aside as old-%s (%s).", oldName, oldName, tostring(why)))
|
||||||
|
debugPrint(string.format("Engine Update: it stays in the folder; the launcher runs the newest Singe there, which is %s.", offered.name))
|
||||||
|
updateSaying = "Restarting into " .. offered.version .. ". " .. oldName .. " could not be set aside, so it stays in the folder; the launcher still starts the newer one."
|
||||||
|
end
|
||||||
|
updateShow()
|
||||||
|
saveSet("releaseOffered", false)
|
||||||
|
saveSet("releaseCheckedAt", 0)
|
||||||
|
-- --refresh on the way in: the new Singe puts the old support folder aside as
|
||||||
|
-- old-Singe-<version> and writes a fresh one, so a file this version no longer ships
|
||||||
|
-- does not linger. Nothing is deleted; the kept folder is the operator's to remove.
|
||||||
|
if not singeRelaunch(newPath, "--refresh") then
|
||||||
|
if moved then
|
||||||
|
os.rename(aside, info.executable)
|
||||||
|
end
|
||||||
|
updateSaying = "The new Singe is beside this one, but it could not be started; start it by hand."
|
||||||
|
updateShow()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS[#TOOLS + 1] = {
|
||||||
|
name = "Engine Update",
|
||||||
|
help = "A newer Singe from the service, fetched and started when you say so.",
|
||||||
|
|
||||||
|
begin = function()
|
||||||
|
updateSaying = ""
|
||||||
|
updateShow()
|
||||||
|
end,
|
||||||
|
|
||||||
|
input = function(what)
|
||||||
|
if what == SWITCH_BUTTON1 and MENU_RELEASE and not updateBusy then
|
||||||
|
updateFetch()
|
||||||
|
return true
|
||||||
|
elseif what == SWITCH_BUTTON3 and not updateBusy then
|
||||||
|
updateSaying = "Looking..."
|
||||||
|
updateShow()
|
||||||
|
local info = singeGetSystemInfo()
|
||||||
|
masterRelease(info.platform, info.version, function(ok, reply)
|
||||||
|
if ok then
|
||||||
|
MENU_RELEASE = reply.newer and reply or nil
|
||||||
|
saveSet("releaseCheckedAt", os.time())
|
||||||
|
saveSet("releaseOffered", MENU_RELEASE or false)
|
||||||
|
updateSaying = reply.newer and ("Singe " .. reply.version .. " is offered.") or "This is the newest Singe for this machine."
|
||||||
|
menuRefreshNotice()
|
||||||
|
else
|
||||||
|
updateSaying = "Could not ask: " .. tostring(reply)
|
||||||
|
end
|
||||||
|
updateShow()
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
-- ===== 11. Online account =====
|
-- ===== 11. Online account =====
|
||||||
|
|
||||||
-- Signing this cabinet in to the master service, and saying where that service is. It belongs
|
-- Signing this cabinet in to the master service, and saying where that service is. It belongs
|
||||||
|
|
@ -1189,12 +1345,62 @@ local function shopLocalName(game)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Puts a file aside instead of writing over it: "Rocks.game" becomes "Rocks.game.old", or the
|
||||||
|
-- first free ".old-2" after that. The name deliberately stops ending in ".game", so the menu does
|
||||||
|
-- not list the kept copy as a second game. Answers the name it was kept under, or nil when there
|
||||||
|
-- was nothing there.
|
||||||
|
--
|
||||||
|
-- Used two ways. For something there is no getting back -- the running engine -- the copy is kept
|
||||||
|
-- for good and removing it is the operator's business. For something the service or the editor
|
||||||
|
-- can make again, replaceFile below uses it only to make the swap safe and then lets it go: a
|
||||||
|
-- game is gigabytes and every version of it is still on the service, so keeping one would cost a
|
||||||
|
-- cabinet its disk to save something nobody needs saving.
|
||||||
|
function keepAside(path)
|
||||||
|
local target = path .. ".old"
|
||||||
|
local n = 1
|
||||||
|
|
||||||
|
if not lfs.attributes(path) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
while lfs.attributes(target) and (n < 100) do
|
||||||
|
n = n + 1
|
||||||
|
target = path .. ".old-" .. n
|
||||||
|
end
|
||||||
|
if os.rename(path, target) then
|
||||||
|
return target
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Moves a downloaded file into place, safely: what is there goes aside first, so a rename that
|
||||||
|
-- fails leaves the working copy where it was rather than a hole where it used to be. The old copy
|
||||||
|
-- is let go once the new one is in place, because everything this is used for -- a game, an
|
||||||
|
-- export -- can be had again. Answers true, or false and what to tell the person.
|
||||||
|
function replaceFile(path, incoming)
|
||||||
|
local kept = keepAside(path)
|
||||||
|
|
||||||
|
if not os.rename(incoming, path) then
|
||||||
|
if kept then
|
||||||
|
os.rename(kept, path)
|
||||||
|
end
|
||||||
|
return false, "could not put it in place; nothing was changed"
|
||||||
|
end
|
||||||
|
if kept then
|
||||||
|
os.remove(kept)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
local function shopInstalledVersion(game)
|
local function shopInstalledVersion(game)
|
||||||
-- What is on this machine, which is not necessarily what the service was last told.
|
-- What is on this machine, read off the games.dat the menu loaded (VERSION, stamped into the
|
||||||
|
-- package by the service when it was published), or 0 for a copy that predates that. The
|
||||||
|
-- service keeps no record of it; this machine is the only one that knows.
|
||||||
if not lfs.attributes(shopLocalName(game)) then
|
if not lfs.attributes(shopLocalName(game)) then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return game.installed or 0
|
return (MENU_VERSIONS and MENU_VERSIONS[game.id]) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1271,6 +1477,45 @@ local function shopRefresh()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Fetch one version of a game into place. Shared by the Get Games tool and by the menu's own
|
||||||
|
-- update button, so there is one download path. game needs id, slug, title, version, and sha256;
|
||||||
|
-- onProgress(text) is called as it goes and onDone(ok, text) once, with what to tell the player.
|
||||||
|
function shopInstall(game, onProgress, onDone)
|
||||||
|
-- An update goes over the file that holds the game, whatever it is called; a fresh download
|
||||||
|
-- takes the service's name for it.
|
||||||
|
local target = game.target or shopLocalName(game)
|
||||||
|
local partial = target .. ".part"
|
||||||
|
|
||||||
|
onProgress("Starting...")
|
||||||
|
masterDownload(game.id, game.version, partial, game.sha256,
|
||||||
|
function(got, total)
|
||||||
|
onProgress(total and string.format("%s: %d%% (%d of %d bytes)", game.title,
|
||||||
|
math.floor(got * 100 / total), got, total)
|
||||||
|
or string.format("%s: %d bytes", game.title, got))
|
||||||
|
end,
|
||||||
|
function(ok, result)
|
||||||
|
if not ok then
|
||||||
|
return onDone(false, "Sorry: " .. tostring(result))
|
||||||
|
end
|
||||||
|
-- Only now is the old copy touched, and the swap is made so that a failure leaves the
|
||||||
|
-- working copy where it was.
|
||||||
|
local moved, why = replaceFile(target, partial)
|
||||||
|
|
||||||
|
if not moved then
|
||||||
|
return onDone(false, "Downloaded, but " .. why .. ".")
|
||||||
|
end
|
||||||
|
-- The file itself now says which version it is; the menu remembers until it next
|
||||||
|
-- reads the games.dat inside it.
|
||||||
|
MENU_VERSIONS = MENU_VERSIONS or {}
|
||||||
|
MENU_VERSIONS[game.id] = game.version
|
||||||
|
if MENU_UPDATES then
|
||||||
|
MENU_UPDATES[game.id] = nil
|
||||||
|
end
|
||||||
|
onDone(true, game.title .. " is ready. " .. result.bytes .. " bytes.")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
local function shopDownload()
|
local function shopDownload()
|
||||||
local game = shopGames[shopSelected]
|
local game = shopGames[shopSelected]
|
||||||
|
|
||||||
|
|
@ -1282,35 +1527,16 @@ local function shopDownload()
|
||||||
shopShow()
|
shopShow()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local target = shopLocalName(game)
|
|
||||||
local partial = target .. ".part"
|
|
||||||
shopBusy = true
|
shopBusy = true
|
||||||
shopProgress = "Starting..."
|
|
||||||
shopShow()
|
shopShow()
|
||||||
masterDownload(game.id, game.version, partial, game.sha256,
|
shopInstall(game,
|
||||||
function(got, total)
|
function(text)
|
||||||
shopProgress = total and string.format("%s: %d%% (%d of %d bytes)", game.title,
|
shopProgress = text
|
||||||
math.floor(got * 100 / total), got, total)
|
|
||||||
or string.format("%s: %d bytes", game.title, got)
|
|
||||||
end,
|
end,
|
||||||
function(ok, result)
|
function(ok, text)
|
||||||
shopBusy = false
|
shopBusy = false
|
||||||
shopProgress = nil
|
shopProgress = nil
|
||||||
if not ok then
|
shopSaying = text
|
||||||
shopSaying = "Sorry: " .. tostring(result)
|
|
||||||
else
|
|
||||||
-- Only now does the old copy get replaced, so a failed download never destroys a
|
|
||||||
-- working install.
|
|
||||||
os.remove(target)
|
|
||||||
if os.rename(partial, target) then
|
|
||||||
game.installed = game.version
|
|
||||||
shopSaying = game.title .. " is ready. " .. result.bytes .. " bytes."
|
|
||||||
masterTellInstalled(game.id, game.version)
|
|
||||||
else
|
|
||||||
os.remove(partial)
|
|
||||||
shopSaying = "Downloaded, but could not put it in place."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
shopShow()
|
shopShow()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -1326,10 +1552,9 @@ local function shopRemove()
|
||||||
shopSaying = "That one is not installed here."
|
shopSaying = "That one is not installed here."
|
||||||
else
|
else
|
||||||
os.remove(shopLocalName(game))
|
os.remove(shopLocalName(game))
|
||||||
game.installed = nil
|
|
||||||
shopSaying = game.title .. " removed. Its saved data is kept."
|
shopSaying = game.title .. " removed. Its saved data is kept."
|
||||||
if game.id then
|
if MENU_VERSIONS and game.id then
|
||||||
masterTellInstalled(game.id, nil)
|
MENU_VERSIONS[game.id] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
shopShow()
|
shopShow()
|
||||||
|
|
@ -1341,6 +1566,8 @@ TOOLS[#TOOLS + 1] = {
|
||||||
help = "Everything the service offers: download, update, remove.",
|
help = "Everything the service offers: download, update, remove.",
|
||||||
|
|
||||||
begin = function()
|
begin = function()
|
||||||
|
-- Fresh word on what is newer than what is here, for the badges and for this page.
|
||||||
|
menuCheckVersions()
|
||||||
shopProgress = nil
|
shopProgress = nil
|
||||||
if masterSignedIn() and #shopGames == 0 then
|
if masterSignedIn() and #shopGames == 0 then
|
||||||
shopRefresh()
|
shopRefresh()
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,22 @@ body {
|
||||||
color: #8fa2c4;
|
color: #8fa2c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A game with a newer version on the service wears a small mark after its title, and the line
|
||||||
|
under its details says which version and which button. Gold, like the selection, since it is
|
||||||
|
the one thing on the page asking for a press. */
|
||||||
|
#list div .badge {
|
||||||
|
margin-left: 6dp;
|
||||||
|
padding: 0 4dp;
|
||||||
|
font-size: 10dp;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #ffd35a;
|
||||||
|
border-radius: 3dp;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notice {
|
||||||
|
color: #ffd35a;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a
|
/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a
|
||||||
page taken out of view has to be said again here or its display wins. */
|
page taken out of view has to be said again here or its display wins. */
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@
|
||||||
-- Cabinets and front ends ------------------------------------------------
|
-- Cabinets and front ends ------------------------------------------------
|
||||||
|
|
||||||
-- idleexit = 0 -- quit after this many seconds with no input; 0 never does
|
-- idleexit = 0 -- quit after this many seconds with no input; 0 never does
|
||||||
|
-- no_update_check = false -- never look for a newer Singe from the menu (Engine Update still works by hand)
|
||||||
-- fvalue = 0 -- one number handed to the game, which reads it with getFValue()
|
-- fvalue = 0 -- one number handed to the game, which reads it with getFValue()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,100 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build the Singe manual from docs/Manual.adoc, the Forge manual from docs/Forge.adoc, and the
|
# Build the four books: the user manual (docs/Manual.adoc, which ships inside the binary), the
|
||||||
# beginner's course from docs/Learn.adoc. Produces .builddir/Manual.html, Manual.pdf, Forge.html,
|
# programming reference (docs/Reference.adoc), the Forge manual (docs/Forge.adoc), and the
|
||||||
# Forge.pdf, Learn.html and Learn.pdf. The version comes from CMakeLists.txt.
|
# beginner's course (docs/Learn.adoc). Produces .builddir/<Book>.html and .pdf for each, and then
|
||||||
|
# packs the three books that do not ship in the binary, with the lesson files and the licence, into
|
||||||
|
# .builddir/Singe-Docs-v<version>.zip, the optional documentation download. The version comes from
|
||||||
|
# CMakeLists.txt.
|
||||||
|
#
|
||||||
|
# ./build-docs.sh the books as they ship
|
||||||
|
# ./build-docs.sh preview the same, with PREVIEW across every page, for proofreaders; the
|
||||||
|
# download is then named -preview so it cannot be mistaken for a
|
||||||
|
# release, and the binary's own copy of the manual (rendered by
|
||||||
|
# CMake, not here) is never touched.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
here=$(cd "$(dirname "$0")" && pwd)
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
preview=
|
||||||
|
suffix=
|
||||||
|
if [[ "${1:-}" == "preview" ]]; then
|
||||||
|
preview=1
|
||||||
|
suffix=-preview
|
||||||
|
elif [[ -n "${1:-}" ]]; then
|
||||||
|
echo "usage: $0 [preview]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
src=$here/docs/Manual.adoc
|
src=$here/docs/Manual.adoc
|
||||||
forge=$here/docs/Forge.adoc
|
forge=$here/docs/Forge.adoc
|
||||||
learn=$here/docs/Learn.adoc
|
learn=$here/docs/Learn.adoc
|
||||||
|
ref=$here/docs/Reference.adoc
|
||||||
|
|
||||||
for doc in "$src" "$forge" "$learn"; do
|
for doc in "$src" "$forge" "$learn" "$ref"; do
|
||||||
if [[ ! -f $doc ]]; then
|
if [[ ! -f $doc ]]; then
|
||||||
echo "error: $doc not found" >&2
|
echo "error: $doc not found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
for tool in asciidoctor asciidoctor-pdf lua5.4; do
|
for tool in asciidoctor asciidoctor-pdf lua5.4 zip; do
|
||||||
if ! command -v "$tool" > /dev/null; then
|
if ! command -v "$tool" > /dev/null; then
|
||||||
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
|
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
version=$(sed -n 's/^project(singe2 VERSION \([0-9.]*\).*/\1/p' "$here/CMakeLists.txt")
|
# The version, from the four numbers of the project() line: patch 1 means a beta and tweak says
|
||||||
|
# which, so 3.00.1.2 is the books' "3.00b2", exactly as the binary is named.
|
||||||
|
read -r major minor patch tweak <<< "$(sed -n 's/^project(singe2 VERSION \([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1 \2 \3 \4/p' "$here/CMakeLists.txt")"
|
||||||
|
if [[ $patch == 1 ]]; then
|
||||||
|
version="${major}.${minor}b${tweak}"
|
||||||
|
else
|
||||||
|
version="${major}.${minor}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The watermark, when asked for: a page background for the PDFs, a head snippet for the HTML.
|
||||||
|
pdfOpts=()
|
||||||
|
# The HTML pages carry their pictures inside them (data-uri), so a page is one file wherever it is
|
||||||
|
# copied and the download needs no images folder beside it.
|
||||||
|
htmlOpts=(-a data-uri)
|
||||||
|
if [[ -n $preview ]]; then
|
||||||
|
pdfOpts=(-a "page-background-image=image:$here/docs/images/preview.svg[fit=fill]")
|
||||||
|
htmlOpts+=(-a docinfo=shared -a "docinfodir=$here/docs/preview")
|
||||||
|
fi
|
||||||
|
|
||||||
out=$here/.builddir
|
out=$here/.builddir
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it.
|
# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it.
|
||||||
lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc"
|
lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc"
|
||||||
asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html"
|
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.html"
|
||||||
asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf"
|
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.pdf"
|
||||||
asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html"
|
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.html"
|
||||||
asciidoctor-pdf -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
|
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
|
||||||
asciidoctor -a revnumber="$version" "$learn" -o "$out/Learn.html"
|
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.html"
|
||||||
asciidoctor-pdf -a revnumber="$version" "$learn" -o "$out/Learn.pdf"
|
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.pdf"
|
||||||
|
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$ref" -o "$out/Reference.html"
|
||||||
|
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$ref" -o "$out/Reference.pdf"
|
||||||
|
|
||||||
|
# The documentation download: everything but the user manual, which the binary carries. Built in
|
||||||
|
# a staging folder so the zip has one top-level directory and no stray build files.
|
||||||
|
stage=$out/Singe-Docs-v$version$suffix
|
||||||
|
rm -rf "$stage" "$stage.zip"
|
||||||
|
mkdir -p "$stage/learn"
|
||||||
|
cp "$out/Reference.pdf" "$out/Reference.html" "$out/Forge.pdf" "$out/Forge.html" "$out/Learn.pdf" "$out/Learn.html" "$stage/"
|
||||||
|
cp -r "$here/docs/learn/." "$stage/learn/"
|
||||||
|
cp "$here/COPYING" "$stage/"
|
||||||
|
cat > "$stage/README.txt" <<EOF
|
||||||
|
Singe $version documentation
|
||||||
|
|
||||||
|
Reference.pdf / .html Everything a game can do: the programming model and every function.
|
||||||
|
Learn.pdf / .html Learn to Program with Singe: thirty lessons from nothing to a finished game.
|
||||||
|
Forge.pdf / .html Forge, the authoring tool: ten tutorials and the vocabulary.
|
||||||
|
learn/ The finished script of every lesson, and the art kit the lessons use.
|
||||||
|
COPYING The licence, GPL version 3.
|
||||||
|
|
||||||
|
The user manual, Manual.pdf, is inside Singe itself and is unpacked into the Singe folder the first
|
||||||
|
time it runs. Forge is a separate download.
|
||||||
|
EOF
|
||||||
|
(cd "$out" && zip -q -r "Singe-Docs-v$version$suffix.zip" "Singe-Docs-v$version$suffix")
|
||||||
|
rm -rf "$stage"
|
||||||
|
|
||||||
echo "built:"
|
echo "built:"
|
||||||
ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Forge.html" "$out/Forge.pdf" "$out/Learn.html" "$out/Learn.pdf"
|
ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Reference.html" "$out/Reference.pdf" "$out/Forge.html" "$out/Forge.pdf" "$out/Learn.html" "$out/Learn.pdf" "$out/Singe-Docs-v$version$suffix.zip"
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,9 @@ string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
|
||||||
string(TOUPPER ${osInitial} osInitial)
|
string(TOUPPER ${osInitial} osInitial)
|
||||||
# The built file carries the platform's executable suffix; the copy in .builddir/ carries the
|
# The built file carries the platform's executable suffix; the copy in .builddir/ carries the
|
||||||
# preset's, which is the same thing for Windows and lets an experimental build live beside another.
|
# preset's, which is the same thing for Windows and lets an experimental build live beside another.
|
||||||
set(singeBinaryBase "Singe-v${PROJECT_VERSION}-${osInitial}${osRest}-${KANGAROO_ARCH}")
|
# SINGE_VERSION_FULL, not PROJECT_VERSION: the four numbers CMake needs are not the version
|
||||||
|
# anybody sees, and a beta build is named v3.00b2.
|
||||||
|
set(singeBinaryBase "Singe-v${SINGE_VERSION_FULL}-${osInitial}${osRest}-${KANGAROO_ARCH}")
|
||||||
set(singeBinaryName "${singeBinaryBase}${SINGE_SUFFIX}")
|
set(singeBinaryName "${singeBinaryBase}${SINGE_SUFFIX}")
|
||||||
set(singeBinary ${SB_PREFIX}/singe)
|
set(singeBinary ${SB_PREFIX}/singe)
|
||||||
ExternalProject_Add(singe
|
ExternalProject_Add(singe
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ Singe Lua written for you. It is itself a Singe game and is distributed on
|
||||||
its own, beside the engine rather than inside it. This document covers the
|
its own, beside the engine rather than inside it. This document covers the
|
||||||
description format, the vocabulary, the compiler, and the editor. The engine
|
description format, the vocabulary, the compiler, and the editor. The engine
|
||||||
calls the generated code makes (`playerNew`, `collidePointRect`,
|
calls the generated code makes (`playerNew`, `collidePointRect`,
|
||||||
`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe Manual.
|
`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe
|
||||||
|
Reference.
|
||||||
|
|
||||||
== Tutorials
|
== Tutorials
|
||||||
|
|
||||||
|
|
@ -1587,10 +1588,16 @@ event -- and may define any Lua the entries emit or call. A behaviour's
|
||||||
action's `emit` returns the Lua a rule compiles to. A behaviour that reads a
|
action's `emit` returns the Lua a rule compiles to. A behaviour that reads a
|
||||||
key or switch resolves the name with `authorKeyValue` or `authorSwitchValue`,
|
key or switch resolves the name with `authorKeyValue` or `authorSwitchValue`,
|
||||||
and one that wants the presses about nothing in particular -- a trigger --
|
and one that wants the presses about nothing in particular -- a trigger --
|
||||||
sets `instance.hearsAll` and reads them in `on`. The five ActionMax games are
|
sets `instance.hearsAll` and reads them in `on`.
|
||||||
the working example: their `lightSensor` behaviour, which judges a shot the
|
|
||||||
way the emulator did by the disc's pixels under the gun, lives in a
|
A word that turns out to be general belongs in the framework rather than
|
||||||
`Vocabulary.singe` beside them and in no game that is not theirs.
|
beside one game. `lightSensor`, which judges a shot by the picture under the
|
||||||
|
gun, began as the five ActionMax games' own vocabulary and is now one of the
|
||||||
|
behaviours above, with `pictureAt` beside it as a condition any rule can ask;
|
||||||
|
those five name no vocabulary at all any more. The test scene's `bob` is the
|
||||||
|
working example instead, and the rule to apply is the same one: a vocabulary
|
||||||
|
is for a kind of thing this game needs and nothing else does. The moment two
|
||||||
|
games would want it, move it.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
|
|
@ -1879,6 +1886,16 @@ for every named file there first, so the release runs wherever it is put.
|
||||||
`--pack` turns that directory into a `.game` like any other, and it runs on a
|
`--pack` turns that directory into a `.game` like any other, and it runs on a
|
||||||
machine that has never had Forge on it.
|
machine that has never had Forge on it.
|
||||||
|
|
||||||
|
`P` goes one step further: it exports, packs, and sends the package to the
|
||||||
|
master service for review, in one press. It is not in the catalogue until it
|
||||||
|
has been reviewed, and Forge says which version number it was given. Press
|
||||||
|
again after a change and a later version goes up on its own, without taking an
|
||||||
|
earlier one away from anybody who already has it. The account is the machine's,
|
||||||
|
signed in through the menu's Online Account tool; Forge signs nothing in
|
||||||
|
itself, and says so if the machine is not signed in. Every description has a
|
||||||
|
permanent `GAME_ID` from the first time it is saved, which is what a game is
|
||||||
|
known by, so a game published once can be updated for ever.
|
||||||
|
|
||||||
Every built game finds its own directory to load that runtime from, using
|
Every built game finds its own directory to load that runtime from, using
|
||||||
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
|
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
|
||||||
engine was *launched* with, so a game reached by `dofile` -- a test, a
|
engine was *launched* with, so a game reached by `dofile` -- a test, a
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,10 @@
|
||||||
| Plays a laserdisc quick-time-event game the Kimmy Script Engine way (Karis's 2024 framework), from the framework's own tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.
|
| Plays a laserdisc quick-time-event game the Kimmy Script Engine way (Karis's 2024 framework), from the framework's own tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.
|
||||||
| --
|
| --
|
||||||
|
|
||||||
|
| `lightSensor`
|
||||||
|
| Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead.
|
||||||
|
| `high` (number), `left` (number), `low` (number), `player` (number), `top` (number), `trigger` (switch), `x` (number), `y` (number)
|
||||||
|
|
||||||
| `mover`
|
| `mover`
|
||||||
| Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.
|
| Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.
|
||||||
| `clamp` (boolean), `speed` (number)
|
| `clamp` (boolean), `speed` (number)
|
||||||
|
|
@ -430,6 +434,10 @@
|
||||||
| Only the first time this rule would run -- per instance and per room unless scope is "game".
|
| Only the first time this rule would run -- per instance and per room unless scope is "game".
|
||||||
| `scope` (string), `tag` (string)
|
| `scope` (string), `tag` (string)
|
||||||
|
|
||||||
|
| `pictureAt`
|
||||||
|
| The disc's picture at a point is bright, dark, or neither: a flash a game hides in its own video, a colour cue, or a lamp in the scene. A game with no disc reads black, since the engine answers zeroes when nothing is playing.
|
||||||
|
| `high` (number), `is` (string), `low` (number), `x` (number), `y` (number)
|
||||||
|
|
||||||
| `pointerIn`
|
| `pointerIn`
|
||||||
| A player's pointer is over an instance.
|
| A player's pointer is over an instance.
|
||||||
| `entity` (entity), `player` (number)
|
| `entity` (entity), `player` (number)
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,11 @@ different place, for someone who does not want to write code at all. If you
|
||||||
came here to learn to program, you are in the right place. You do not need to
|
came here to learn to program, you are in the right place. You do not need to
|
||||||
read the Forge book, and this one does not build on it.
|
read the Forge book, and this one does not build on it.
|
||||||
|
|
||||||
The _Singe Manual_ is the reference: every function, every argument, every
|
The _Singe Reference_ is the reference: every function, every argument, every
|
||||||
return value. It is not a tutorial and does not try to be one. You will start
|
return value, and the programming model behind them. It is not a tutorial and
|
||||||
reaching for it around lesson six, and by the end of this book you will use it
|
does not try to be one. You will start reaching for it around lesson six, and
|
||||||
more than you use this book.
|
by the end of this book you will use it more than you use this book. It comes
|
||||||
|
in the same download as this one.
|
||||||
|
|
||||||
== The Series
|
== The Series
|
||||||
|
|
||||||
|
|
|
||||||
18721
docs/Manual.adoc
18721
docs/Manual.adoc
File diff suppressed because it is too large
Load diff
18605
docs/Reference.adoc
Normal file
18605
docs/Reference.adoc
Normal file
File diff suppressed because it is too large
Load diff
BIN
docs/images/learn/09-pictures.png
(Stored with Git LFS)
BIN
docs/images/learn/09-pictures.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/images/learn/10-sound.png
(Stored with Git LFS)
BIN
docs/images/learn/10-sound.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/images/learn/11-hitting-things.png
(Stored with Git LFS)
BIN
docs/images/learn/11-hitting-things.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/images/learn/12-score.png
(Stored with Git LFS)
BIN
docs/images/learn/12-score.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/images/learn/13-game-folder.png
(Stored with Git LFS)
BIN
docs/images/learn/13-game-folder.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/images/learn/20-gui.png
(Stored with Git LFS)
BIN
docs/images/learn/20-gui.png
(Stored with Git LFS)
Binary file not shown.
BIN
docs/images/learn/25-particles.png
(Stored with Git LFS)
BIN
docs/images/learn/25-particles.png
(Stored with Git LFS)
Binary file not shown.
9
docs/images/preview.svg
Normal file
9
docs/images/preview.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- The proofreading watermark: laid under every page of a PDF built with ./build-docs.sh
|
||||||
|
preview, and never under a release build. One word, diagonal, faint enough to read through. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="595" height="842" viewBox="0 0 595 842">
|
||||||
|
<text x="297" y="421" text-anchor="middle" dominant-baseline="middle"
|
||||||
|
font-family="Helvetica" font-weight="bold" font-size="150"
|
||||||
|
fill="#000000" fill-opacity="0.11"
|
||||||
|
transform="rotate(-50 297 421)">PREVIEW</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 565 B |
|
|
@ -8,9 +8,9 @@ local STAR_COUNT = 40
|
||||||
local START_LIVES = 3
|
local START_LIVES = 3
|
||||||
local HIT_SCORE = 10
|
local HIT_SCORE = 10
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local starSprite = spriteLoad(DIR .. "art/star.png")
|
local starSprite = spriteLoad(DIR .. "art/star.png")
|
||||||
|
|
||||||
local screenWidth = overlayGetWidth()
|
local screenWidth = overlayGetWidth()
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ local STAR_COUNT = 40
|
||||||
local START_LIVES = 3
|
local START_LIVES = 3
|
||||||
local HIT_SCORE = 10
|
local HIT_SCORE = 10
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local starSprite = spriteLoad(DIR .. "art/star.png")
|
local starSprite = spriteLoad(DIR .. "art/star.png")
|
||||||
|
|
||||||
local shootClip = soundLoad(DIR .. "art/shoot.wav")
|
local shootClip = soundLoad(DIR .. "art/shoot.wav")
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ local SHOT_HEIGHT = 10
|
||||||
local SHOT_SPEED = 6
|
local SHOT_SPEED = 6
|
||||||
local SHOW_BOXES = false
|
local SHOW_BOXES = false
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ local TITLE = "title"
|
||||||
local PLAYING = "playing"
|
local PLAYING = "playing"
|
||||||
local OVER = "over"
|
local OVER = "over"
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ local TITLE = "title"
|
||||||
local PLAYING = "playing"
|
local PLAYING = "playing"
|
||||||
local OVER = "over"
|
local OVER = "over"
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ singeSetPauseKeyEnabled(false)
|
||||||
|
|
||||||
local SHIP_Y = 300
|
local SHIP_Y = 300
|
||||||
|
|
||||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
|
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
|
||||||
local shipX = 0
|
local shipX = 0
|
||||||
local speed = 2
|
local speed = 2
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ local SHIP_HEIGHT = 24
|
||||||
local SHIP_SPEED = 120
|
local SHIP_SPEED = 120
|
||||||
local RESPAWN_MS = 1400
|
local RESPAWN_MS = 1400
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local sparkSprite = spriteLoad(DIR .. "art/star.png")
|
local sparkSprite = spriteLoad(DIR .. "art/star.png")
|
||||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ The art kit for "Learn to Program with Singe"
|
||||||
Copy this folder next to your script, keeping the name "art", and the lessons
|
Copy this folder next to your script, keeping the name "art", and the lessons
|
||||||
will find these files where they say they are.
|
will find these files where they say they are.
|
||||||
|
|
||||||
ship.png 32 x 24 A ship pointing up.
|
spaceship.png 32 x 24 A ship pointing up.
|
||||||
rock.png 24 x 24 An asteroid.
|
asteroid.png 24 x 24 An asteroid.
|
||||||
shot.png 4 x 10 A bullet.
|
bullet.png 4 x 10 A bullet.
|
||||||
star.png 8 x 8 A small star, also used as a spark.
|
star.png 8 x 8 A small star, also used as a spark.
|
||||||
walk.png 96 x 32 Four frames of a walk cycle, each 24 x 32, side by side.
|
walk.png 96 x 32 Four frames of a walk cycle, each 24 x 32, side by side.
|
||||||
shoot.wav A shot.
|
shoot.wav A shot.
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ first thing beginners get wrong.
|
||||||
`onOverlayUpdate`, which asks "is the player holding left?" sixty times a
|
`onOverlayUpdate`, which asks "is the player holding left?" sixty times a
|
||||||
second. If you moved the player inside `onInputPressed` instead, you would get
|
second. If you moved the player inside `onInputPressed` instead, you would get
|
||||||
one step per press: Singe sends a press exactly once when the key goes down
|
one step per press: Singe sends a press exactly once when the key goes down
|
||||||
and never repeats it, which the manual's entry for `onInputPressed` says
|
and never repeats it, which the reference's entry for `onInputPressed` says
|
||||||
plainly. Holding the key would do nothing at all.
|
plainly. Holding the key would do nothing at all.
|
||||||
|
|
||||||
So the press handler records what is true about the world, and the update
|
So the press handler records what is true about the world, and the update
|
||||||
|
|
@ -753,7 +753,7 @@ draws the player, sets red, and then draws all the blocks without mentioning
|
||||||
colour again.
|
colour again.
|
||||||
|
|
||||||
It does not affect `overlayPrint`. The console font is copied onto the overlay
|
It does not affect `overlayPrint`. The console font is copied onto the overlay
|
||||||
exactly as it is, in its own colours, which the manual's entry for
|
exactly as it is, in its own colours, which the reference's entry for
|
||||||
`overlayPrint` says in as many words. Coloured text needs a loaded font, and
|
`overlayPrint` says in as many words. Coloured text needs a loaded font, and
|
||||||
that is lesson nineteen.
|
that is lesson nineteen.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ The sibling message counts rather than checks:
|
||||||
That is an `overlayBox` with a corner missing. The odd-looking `argument(s)` is
|
That is an `overlayBox` with a corner missing. The odd-looking `argument(s)` is
|
||||||
the engine's, not a typo of yours.
|
the engine's, not a typo of yours.
|
||||||
|
|
||||||
When you get one of these, the manual's entry for that function is the answer,
|
When you get one of these, the reference's entry for that function is the answer,
|
||||||
and it will take you a minute. Every entry lists the arguments in order with
|
and it will take you a minute. Every entry lists the arguments in order with
|
||||||
what each one has to be. Singe checks them because the alternative is a game
|
what each one has to be. Singe checks them because the alternative is a game
|
||||||
that runs for ten minutes and then draws garbage.
|
that runs for ten minutes and then draws garbage.
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ singe/ <- your work folder
|
||||||
rocks/
|
rocks/
|
||||||
rocks.singe
|
rocks.singe
|
||||||
art/
|
art/
|
||||||
ship.png
|
spaceship.png
|
||||||
rock.png
|
asteroid.png
|
||||||
shot.png
|
bullet.png
|
||||||
star.png
|
star.png
|
||||||
walk.png
|
walk.png
|
||||||
shoot.wav
|
shoot.wav
|
||||||
|
|
@ -93,7 +93,7 @@ Start a new file next to your `art` folder and type this in.
|
||||||
----
|
----
|
||||||
dofile("Singe/Framework.singe")
|
dofile("Singe/Framework.singe")
|
||||||
|
|
||||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
|
|
||||||
|
|
||||||
function onOverlayUpdate()
|
function onOverlayUpdate()
|
||||||
|
|
@ -116,7 +116,7 @@ Four new things, one at a time.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
----
|
----
|
||||||
|
|
||||||
`spriteLoad` reads an image file off the disk, decodes it, and keeps the
|
`spriteLoad` reads an image file off the disk, decodes it, and keeps the
|
||||||
|
|
@ -134,12 +134,12 @@ exactly what a variable is for.
|
||||||
`DIR` is new too, and it solves a problem the layout you have been using since
|
`DIR` is new too, and it solves a problem the layout you have been using since
|
||||||
lesson one creates. Singe looks for files relative to the folder you were
|
lesson one creates. Singe looks for files relative to the folder you were
|
||||||
standing in when you started it, *not* relative to your script. You start it
|
standing in when you started it, *not* relative to your script. You start it
|
||||||
from your work folder, so a plain `art/ship.png` sends the engine looking for
|
from your work folder, so a plain `art/spaceship.png` sends the engine looking for
|
||||||
`singe/art/ship.png`, out beside the `Singe` folder. Your artwork is not
|
`singe/art/spaceship.png`, out beside the `Singe` folder. Your artwork is not
|
||||||
there. It is one level down, in `rocks/art/ship.png`, with your script.
|
there. It is one level down, in `rocks/art/spaceship.png`, with your script.
|
||||||
|
|
||||||
`DIR` closes that gap. It is a piece of text holding the folder your script is
|
`DIR` closes that gap. It is a piece of text holding the folder your script is
|
||||||
in, with the slash already on the end, so `DIR .. "art/ship.png"` -- joining
|
in, with the slash already on the end, so `DIR .. "art/spaceship.png"` -- joining
|
||||||
two pieces of text with `..`, which you have been doing since part one --
|
two pieces of text with `..`, which you have been doing since part one --
|
||||||
names the picture correctly however the game was started, and keeps naming it
|
names the picture correctly however the game was started, and keeps naming it
|
||||||
correctly when you move the whole folder somewhere else.
|
correctly when you move the whole folder somewhere else.
|
||||||
|
|
@ -173,7 +173,7 @@ If you would rather give the middle, say so:
|
||||||
----
|
----
|
||||||
|
|
||||||
A fourth argument of `true` means "treat that point as the centre". Both forms
|
A fourth argument of `true` means "treat that point as the centre". Both forms
|
||||||
are in the manual's entry for `spriteDraw`, along with two more that stretch
|
are in the reference's entry for `spriteDraw`, along with two more that stretch
|
||||||
the picture into a rectangle.
|
the picture into a rectangle.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
|
|
@ -227,7 +227,7 @@ reasonable:
|
||||||
----
|
----
|
||||||
function onOverlayUpdate()
|
function onOverlayUpdate()
|
||||||
overlayClear()
|
overlayClear()
|
||||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
spriteDraw(ship, 160, 100)
|
spriteDraw(ship, 160, 100)
|
||||||
return OVERLAY_UPDATED
|
return OVERLAY_UPDATED
|
||||||
end
|
end
|
||||||
|
|
@ -358,7 +358,7 @@ leftmost figure and frame 4 the rightmost.
|
||||||
|
|
||||||
Watch out for that 1. Elsewhere in the sprite family -- `spriteSetFrame` and
|
Watch out for that 1. Elsewhere in the sprite family -- `spriteSetFrame` and
|
||||||
`spriteGetFrame` -- frames are numbered from 0. That is an accident of
|
`spriteGetFrame` -- frames are numbered from 0. That is an accident of
|
||||||
history, both are in the manual, and this book uses `spriteDrawFrame` and
|
history, both are in the reference, and this book uses `spriteDrawFrame` and
|
||||||
counts from 1.
|
counts from 1.
|
||||||
|
|
||||||
`spriteFrameWidth` reports the width of one frame, 24 here, rather than the 96
|
`spriteFrameWidth` reports the width of one frame, 24 here, rather than the 96
|
||||||
|
|
@ -415,9 +415,9 @@ local STAR_COUNT = 40
|
||||||
local START_LIVES = 3
|
local START_LIVES = 3
|
||||||
local HIT_SCORE = 10
|
local HIT_SCORE = 10
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local starSprite = spriteLoad(DIR .. "art/star.png")
|
local starSprite = spriteLoad(DIR .. "art/star.png")
|
||||||
|
|
||||||
local screenWidth = overlayGetWidth()
|
local screenWidth = overlayGetWidth()
|
||||||
|
|
@ -664,7 +664,7 @@ One last note for when you read other people's Singe code. Every sprite call
|
||||||
in this book takes the handle *first*. Singe 2.10 put it last, and a game can
|
in this book takes the handle *first*. Singe 2.10 put it last, and a game can
|
||||||
ask the engine to keep doing that, so code you find online may read
|
ask the engine to keep doing that, so code you find online may read
|
||||||
`spriteDraw(x, y, ship)`. Do not copy it. Handle first is the current order
|
`spriteDraw(x, y, ship)`. Do not copy it. Handle first is the current order
|
||||||
and the one the manual documents.
|
and the one the reference documents.
|
||||||
|
|
||||||
=== Try It
|
=== Try It
|
||||||
|
|
||||||
|
|
@ -712,7 +712,7 @@ operating system's own wording and will differ slightly from machine to
|
||||||
machine.
|
machine.
|
||||||
|
|
||||||
`No such file or directory` is the computer being precise rather than unkind.
|
`No such file or directory` is the computer being precise rather than unkind.
|
||||||
There genuinely is no file called `Ship.png`; there is one called `ship.png`,
|
There genuinely is no file called `Ship.png`; there is one called `spaceship.png`,
|
||||||
and to a Linux or macOS machine those are two different names. On Windows it
|
and to a Linux or macOS machine those are two different names. On Windows it
|
||||||
would have worked, which is worse, because it means the bug travels to
|
would have worked, which is worse, because it means the bug travels to
|
||||||
somebody else's machine and appears there for the first time.
|
somebody else's machine and appears there for the first time.
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,7 @@ It is ten seconds long and it was written to fade out at the end, so looping
|
||||||
it sounds exactly like a ten second loop that keeps fading out. That is fine
|
it sounds exactly like a ten second loop that keeps fading out. That is fine
|
||||||
for learning the calls and no good at all for a finished
|
for learning the calls and no good at all for a finished
|
||||||
game, which ships a longer piece of its own. Any of the usual formats will do:
|
game, which ships a longer piece of its own. Any of the usual formats will do:
|
||||||
the manual's Music section lists them, and Ogg Vorbis or MP3 are the sensible
|
the reference's Music section lists them, and Ogg Vorbis or MP3 are the sensible
|
||||||
choices, because a few minutes of music as a WAV is an enormous file.
|
choices, because a few minutes of music as a WAV is an enormous file.
|
||||||
|
|
||||||
=== The Game Gets a Soundtrack
|
=== The Game Gets a Soundtrack
|
||||||
|
|
@ -545,7 +545,7 @@ The game refuses to start:
|
||||||
|
|
||||||
The line number you get is the line you actually typed it on, so yours may not
|
The line number you get is the line you actually typed it on, so yours may not
|
||||||
be 20. Then the function that objected, and the value it objected to. The
|
be 20. Then the function that objected, and the value it objected to. The
|
||||||
message does not tell you what the range is -- that is what the manual's entry
|
message does not tell you what the range is -- that is what the reference's entry
|
||||||
for `soundSetVolume` is for -- but it does tell you the number it would not
|
for `soundSetVolume` is for -- but it does tell you the number it would not
|
||||||
take, and once you know that effects stop at 63 the fix is one digit.
|
take, and once you know that effects stop at 63 the fix is one digit.
|
||||||
|
|
||||||
|
|
@ -557,7 +557,7 @@ anything.
|
||||||
|
|
||||||
This is the general shape of an engine error, as opposed to the Lua errors in
|
This is the general shape of an engine error, as opposed to the Lua errors in
|
||||||
lesson one: a line number, the name of the engine function you called, and a
|
lesson one: a line number, the name of the engine function you called, and a
|
||||||
complaint about the values you handed it. When you see one, the manual's entry
|
complaint about the values you handed it. When you see one, the reference's entry
|
||||||
for that function is the next thing to read.
|
for that function is the next thing to read.
|
||||||
|
|
||||||
=== What You Learned
|
=== What You Learned
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ this, with the details of the rocks left out:
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||||
----
|
----
|
||||||
|
|
@ -84,7 +84,7 @@ end
|
||||||
Eight numbers in, `true` or `false` out, in the same order you just wrote them:
|
Eight numbers in, `true` or `false` out, in the same order you just wrote them:
|
||||||
the first rectangle's corner and size, then the second one's. It is one of a
|
the first rectangle's corner and size, then the second one's. It is one of a
|
||||||
small family of collision functions, all of them plain questions with no
|
small family of collision functions, all of them plain questions with no
|
||||||
handles to keep and nothing to set up, and the manual's Collide section lists
|
handles to keep and nothing to set up, and the reference's Collide section lists
|
||||||
every one. Two edges that touch exactly count as touching, and a rectangle with
|
every one. Two edges that touch exactly count as touching, and a rectangle with
|
||||||
no width or height touches nothing at all.
|
no width or height touches nothing at all.
|
||||||
|
|
||||||
|
|
@ -151,7 +151,7 @@ number rather than by hunting through your tests.
|
||||||
|
|
||||||
=== Firing Back
|
=== Firing Back
|
||||||
|
|
||||||
A shot is a small record in a list, exactly like a rock. `shot.png` is 4 wide
|
A shot is a small record in a list, exactly like a rock. `bullet.png` is 4 wide
|
||||||
and 10 tall, and shots travel up the screen instead of down:
|
and 10 tall, and shots travel up the screen instead of down:
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
|
|
@ -160,7 +160,7 @@ local SHOT_WIDTH = 4
|
||||||
local SHOT_HEIGHT = 10
|
local SHOT_HEIGHT = 10
|
||||||
local SHOT_SPEED = 6
|
local SHOT_SPEED = 6
|
||||||
|
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local shots = {}
|
local shots = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -416,7 +416,7 @@ many there are, and asking is cheap.
|
||||||
And when you want to know not just *whether* two things met but where the
|
And when you want to know not just *whether* two things met but where the
|
||||||
moving one should end up -- sliding along a wall instead of stopping dead
|
moving one should end up -- sliding along a wall instead of stopping dead
|
||||||
inside it -- the collide calls deliberately do not answer that. A library
|
inside it -- the collide calls deliberately do not answer that. A library
|
||||||
called `bump` is bundled with Singe for exactly that job, and the manual's
|
called `bump` is bundled with Singe for exactly that job, and the reference's
|
||||||
Included Libraries section says how to reach it.
|
Included Libraries section says how to reach it.
|
||||||
|
|
||||||
=== Try It
|
=== Try It
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ Rocks/
|
||||||
games.dat What the menu reads
|
games.dat What the menu reads
|
||||||
Rocks.singe The game
|
Rocks.singe The game
|
||||||
art/
|
art/
|
||||||
ship.png
|
spaceship.png
|
||||||
rock.png
|
asteroid.png
|
||||||
shot.png
|
bullet.png
|
||||||
star.png
|
star.png
|
||||||
shoot.wav
|
shoot.wav
|
||||||
boom.wav
|
boom.wav
|
||||||
|
|
@ -72,7 +72,7 @@ one.
|
||||||
|
|
||||||
Here is the change your script needs, and the reason it needs it.
|
Here is the change your script needs, and the reason it needs it.
|
||||||
|
|
||||||
Every name your game loads -- `art/ship.png`, `art/boom.wav` -- is looked for
|
Every name your game loads -- `art/spaceship.png`, `art/boom.wav` -- is looked for
|
||||||
starting at the folder Singe was *started in*, not the folder your script is
|
starting at the folder Singe was *started in*, not the folder your script is
|
||||||
in. All through this book those have been the same folder, because you stood
|
in. All through this book those have been the same folder, because you stood
|
||||||
inside the game's folder and typed `Singe rocks`. Start it from the
|
inside the game's folder and typed `Singe rocks`. Start it from the
|
||||||
|
|
@ -85,9 +85,9 @@ your file from wherever Singe was started:
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||||
----
|
----
|
||||||
|
|
@ -220,7 +220,7 @@ again tomorrow.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
----
|
----
|
||||||
|
|
||||||
`DIR` is a variable `Singe/Framework.singe` sets before your script runs, and
|
`DIR` is a variable `Singe/Framework.singe` sets before your script runs, and
|
||||||
|
|
@ -267,19 +267,19 @@ else's.
|
||||||
|
|
||||||
=== Break It on Purpose
|
=== Break It on Purpose
|
||||||
|
|
||||||
Take the `DIR ..` back off one line, so it reads `spriteLoad("art/ship.png")`
|
Take the `DIR ..` back off one line, so it reads `spriteLoad("art/spaceship.png")`
|
||||||
again, and start the game from the folder above -- `Singe Rocks`, or from the
|
again, and start the game from the folder above -- `Singe Rocks`, or from the
|
||||||
menu. The game dies before it draws anything, and the terminal says:
|
menu. The game dies before it draws anything, and the terminal says:
|
||||||
|
|
||||||
----
|
----
|
||||||
24:spriteLoad: Couldn't open art/ship.png: No such file or directory
|
24:spriteLoad: Couldn't open art/spaceship.png: No such file or directory
|
||||||
----
|
----
|
||||||
|
|
||||||
The exact wording after the colon comes from your operating system and differs
|
The exact wording after the colon comes from your operating system and differs
|
||||||
a little between them, but the shape is the same everywhere: the line, the
|
a little between them, but the shape is the same everywhere: the line, the
|
||||||
engine function that gave up, and the name it could not find.
|
engine function that gave up, and the name it could not find.
|
||||||
|
|
||||||
It could not find `art/ship.png` because it looked in the folder you started
|
It could not find `art/spaceship.png` because it looked in the folder you started
|
||||||
Singe in, which is the folder holding `Rocks`, and there is no `art` there.
|
Singe in, which is the folder holding `Rocks`, and there is no `art` there.
|
||||||
The file it wanted is one folder further down, which is exactly what `DIR`
|
The file it wanted is one folder further down, which is exactly what `DIR`
|
||||||
knows and a bare name does not.
|
knows and a bare name does not.
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ draw on is half the size of the video on each side, which for a 720 by 480
|
||||||
video is 360 by 240, which is exactly the size of the canvas you have been
|
video is 360 by 240, which is exactly the size of the canvas you have been
|
||||||
drawing on for thirteen lessons. Your coordinates did not move. If you ever
|
drawing on for thirteen lessons. Your coordinates did not move. If you ever
|
||||||
play a video of some other size, the overlay changes size with it, and
|
play a video of some other size, the overlay changes size with it, and
|
||||||
`overlaySetResolution` sets it to whatever you want; the manual's entry has
|
`overlaySetResolution` sets it to whatever you want; the reference's entry has
|
||||||
the details.
|
the details.
|
||||||
|
|
||||||
This is the whole idea of the overlay. The video is underneath. Your drawing
|
This is the whole idea of the overlay. The video is underneath. Your drawing
|
||||||
|
|
@ -417,7 +417,7 @@ it again, forever. `held` is how you say "I have already done this once".
|
||||||
calls `discSkipToFrame(0)`. You have built an attract mode: a scene that
|
calls `discSkipToFrame(0)`. You have built an attract mode: a scene that
|
||||||
plays forever until somebody presses a button.
|
plays forever until somebody presses a button.
|
||||||
. *Ask for a frame that is not there.* Change the `SWITCH_RIGHT` line to
|
. *Ask for a frame that is not there.* Change the `SWITCH_RIGHT` line to
|
||||||
`discSkipToFrame(100000)`. It does not crash. Read the manual's entry for
|
`discSkipToFrame(100000)`. It does not crash. Read the reference's entry for
|
||||||
`discSkipToFrame` and find the sentence that says why.
|
`discSkipToFrame` and find the sentence that says why.
|
||||||
. *Play it without the video.* Run `Singe -R movie` and then press
|
. *Play it without the video.* Run `Singe -R movie` and then press
|
||||||
every key. Nothing you press can break it.
|
every key. Nothing you press can break it.
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then
|
||||||
A shot is a point. A target is a rectangle. `collidePointRect` asks whether
|
A shot is a point. A target is a rectangle. `collidePointRect` asks whether
|
||||||
the point is inside the rectangle, and the edge counts as inside. There is
|
the point is inside the rectangle, and the edge counts as inside. There is
|
||||||
nothing special about shooting. A gun game's hit test is the same hit test a
|
nothing special about shooting. A gun game's hit test is the same hit test a
|
||||||
platform game uses to find out whether you landed on a block, and the manual's
|
platform game uses to find out whether you landed on a block, and the reference's
|
||||||
`collide` family has the rest of the shapes.
|
`collide` family has the rest of the shapes.
|
||||||
|
|
||||||
=== Targets That Come and Go
|
=== Targets That Come and Go
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ exactly what a final shot wants. The `discGetState()` half stops that
|
||||||
happening again on every one of the following frames: once the disc is paused
|
happening again on every one of the following frames: once the disc is paused
|
||||||
it is no longer `DISC_PLAYING`, so the condition is false and the seek is not
|
it is no longer `DISC_PLAYING`, so the condition is false and the seek is not
|
||||||
repeated sixty times a second. `discGetState` and the `DISC_*` constants are
|
repeated sixty times a second. `discGetState` and the `DISC_*` constants are
|
||||||
in the manual under `discGetState`.
|
in the reference under `discGetState`.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ white, this is why.
|
||||||
|
|
||||||
=== The Trap: fontPrint Is Not Free
|
=== The Trap: fontPrint Is Not Free
|
||||||
|
|
||||||
Here is the thing the manual says plainly and everyone reads past.
|
Here is the thing the reference says plainly and everyone reads past.
|
||||||
|
|
||||||
`fontPrint` does not draw a picture it made earlier. Every single call
|
`fontPrint` does not draw a picture it made earlier. Every single call
|
||||||
rasterizes the whole string from the font outlines, builds an image of it,
|
rasterizes the whole string from the font outlines, builds an image of it,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ dofile("Singe/Framework.singe")
|
||||||
|
|
||||||
overlaySetResolution(discGetWidth(), discGetHeight())
|
overlaySetResolution(discGetWidth(), discGetHeight())
|
||||||
|
|
||||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local shipX = 0
|
local shipX = 0
|
||||||
local speed = 2
|
local speed = 2
|
||||||
local score = 0
|
local score = 0
|
||||||
|
|
@ -211,7 +211,7 @@ that might not be there without dying.
|
||||||
|
|
||||||
That is the whole everyday API: `guiSetHandler`, `guiSetValue`, `guiGetValue`.
|
That is the whole everyday API: `guiSetHandler`, `guiSetValue`, `guiGetValue`.
|
||||||
There is far more available through RmlUi's own Lua objects, reachable from the
|
There is far more available through RmlUi's own Lua objects, reachable from the
|
||||||
global `rmlui`, and the manual's GUI chapter shows how. You will get a long way
|
global `rmlui`, and the reference's GUI chapter shows how. You will get a long way
|
||||||
before you need it.
|
before you need it.
|
||||||
|
|
||||||
=== Hide It Again
|
=== Hide It Again
|
||||||
|
|
@ -341,7 +341,7 @@ singeSetPauseKeyEnabled(false)
|
||||||
|
|
||||||
local SHIP_Y = 300
|
local SHIP_Y = 300
|
||||||
|
|
||||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
|
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
|
||||||
local shipX = 0
|
local shipX = 0
|
||||||
local speed = 2
|
local speed = 2
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ Keep it in a variable, because every later call needs it.
|
||||||
`materialSetRoughness` runs from `0`, a mirror with a tiny hard highlight, to
|
`materialSetRoughness` runs from `0`, a mirror with a tiny hard highlight, to
|
||||||
`1`, a chalky matte surface with no highlight at all. `0.6` is paint on
|
`1`, a chalky matte surface with no highlight at all. `0.6` is paint on
|
||||||
wood. There are a dozen more `materialSet` calls, and the next lesson uses
|
wood. There are a dozen more `materialSet` calls, and the next lesson uses
|
||||||
some of them; the manual's Material section lists them all.
|
some of them; the reference's Material section lists them all.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
|
|
@ -366,7 +366,7 @@ to know how many times the player has spun, count that yourself.
|
||||||
That is the point: one shape, two places.
|
That is the point: one shape, two places.
|
||||||
. *Hang the camera on the box.* Keep the second box from the last step, or
|
. *Hang the camera on the box.* Keep the second box from the last step, or
|
||||||
there will be nothing left to move against. Then look up `nodeSetParent` in
|
there will be nothing left to move against. Then look up `nodeSetParent` in
|
||||||
the manual and hang the camera under the first box. Hold an arrow key: the
|
the reference and hang the camera under the first box. Hold an arrow key: the
|
||||||
camera rides the box's rotation, so the box sits still and the rest of the
|
camera rides the box's rotation, so the box sits still and the rest of the
|
||||||
scene swings past. That is one line away from a first-person view, and it
|
scene swings past. That is one line away from a first-person view, and it
|
||||||
is also how you find out that a chase camera wants a parent rather than
|
is also how you find out that a chase camera wants a parent rather than
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ exactly one.
|
||||||
`lightSetIntensity` is brightness. For a directional light, `1` lights a
|
`lightSetIntensity` is brightness. For a directional light, `1` lights a
|
||||||
white surface square-on to it up to white, so `1.8` is a bright day.
|
white surface square-on to it up to white, so `1.8` is a bright day.
|
||||||
`lightSetColor` tints it: a slightly warm white for sun, a cold blue for
|
`lightSetColor` tints it: a slightly warm white for sun, a cold blue for
|
||||||
moonlight, and the manual's Light section for the rest.
|
moonlight, and the reference's Light section for the rest.
|
||||||
|
|
||||||
`lightSetShadow(sun, true)` is what makes the dragon sit on the floor rather
|
`lightSetShadow(sun, true)` is what makes the dragon sit on the floor rather
|
||||||
than hover above it. Shadows are off by default because each shadow-casting
|
than hover above it. Shadows are off by default because each shadow-casting
|
||||||
|
|
@ -246,7 +246,7 @@ One thing to know about metal: a metal surface shows you its reflections
|
||||||
rather than a color of its own, so in a scene with nothing to reflect it
|
rather than a color of its own, so in a scene with nothing to reflect it
|
||||||
looks dark and dull. That is why the dragon's dark metal needs the sun to
|
looks dark and dull. That is why the dragon's dark metal needs the sun to
|
||||||
come alive. The proper fix is a sky (`sceneSetSky`), which wraps the scene in
|
come alive. The proper fix is a sky (`sceneSetSky`), which wraps the scene in
|
||||||
a panorama that both shows behind everything and lights it; the manual's
|
a panorama that both shows behind everything and lights it; the reference's
|
||||||
"The Look of the Frame" covers it.
|
"The Look of the Frame" covers it.
|
||||||
|
|
||||||
=== Movement the Model Brings with It
|
=== Movement the Model Brings with It
|
||||||
|
|
@ -334,7 +334,7 @@ The wings turn about their own X axis, and they turn around the shoulder
|
||||||
rather than the middle of the animal, because whoever built the file put each
|
rather than the middle of the animal, because whoever built the file put each
|
||||||
part's node where the joint belongs. A model that was not built that way will
|
part's node where the joint belongs. A model that was not built that way will
|
||||||
swing its parts around the model's center instead, and the fix is to move the
|
swing its parts around the model's center instead, and the fix is to move the
|
||||||
part's node, which the manual's Node section covers.
|
part's node, which the reference's Node section covers.
|
||||||
|
|
||||||
Notice that the whole dragon turning does not interfere with the wings
|
Notice that the whole dragon turning does not interfere with the wings
|
||||||
turning. Parts hang under the instance root, so rotating the root carries
|
turning. Parts hang under the instance root, so rotating the root carries
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,7 @@ and nobody can tell you exactly how it should look.
|
||||||
Do not reach for it when you know what should happen. A bullet that flies
|
Do not reach for it when you know what should happen. A bullet that flies
|
||||||
straight is two lines from lesson two. A platform that goes up and down is
|
straight is two lines from lesson two. A platform that goes up and down is
|
||||||
`math.sin`. A player that walks and jumps the way players expect wants a
|
`math.sin`. A player that walks and jumps the way players expect wants a
|
||||||
character controller (`playerNew`, in the manual), because a person is not a
|
character controller (`playerNew`, in the reference), because a person is not a
|
||||||
box and never behaves like one. An enemy that patrols wants a path. A menu
|
box and never behaves like one. An enemy that patrols wants a path. A menu
|
||||||
that slides in wants a tween.
|
that slides in wants a tween.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ local SHIP_WIDTH = 32
|
||||||
local SHIP_HEIGHT = 24
|
local SHIP_HEIGHT = 24
|
||||||
local SHIP_SPEED = 120
|
local SHIP_SPEED = 120
|
||||||
|
|
||||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||||
|
|
||||||
local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
|
local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
|
||||||
local shipY = overlayGetHeight() - 56
|
local shipY = overlayGetHeight() - 56
|
||||||
|
|
@ -94,7 +94,7 @@ from lesson three, and `singeGetTicks` so the ship moves at the same speed
|
||||||
whatever the frame rate is.
|
whatever the frame rate is.
|
||||||
|
|
||||||
The one line worth pointing at is `dofile("Singe/Framework.singe")`. That is
|
The one line worth pointing at is `dofile("Singe/Framework.singe")`. That is
|
||||||
what defines `DIR`, the folder your script is in, so `DIR .. "art/ship.png"`
|
what defines `DIR`, the folder your script is in, so `DIR .. "art/spaceship.png"`
|
||||||
finds the picture no matter which folder you started Singe from.
|
finds the picture no matter which folder you started Singe from.
|
||||||
|
|
||||||
=== Give It an Exhaust
|
=== Give It an Exhaust
|
||||||
|
|
|
||||||
|
|
@ -455,7 +455,7 @@ From here on, do not set that node's position yourself. Two things moving one
|
||||||
node fight, and the fight looks like stuttering. If your character needs to
|
node fight, and the fight looks like stuttering. If your character needs to
|
||||||
be shoved about by physics as well as walk, there is a call for that --
|
be shoved about by physics as well as walk, there is a call for that --
|
||||||
`navAgentSetPlayer` hands the steering to a physics controller instead -- and
|
`navAgentSetPlayer` hands the steering to a physics controller instead -- and
|
||||||
the manual's entry for it explains when you want it.
|
the reference's entry for it explains when you want it.
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
|
|
@ -500,7 +500,7 @@ the speed it is actually travelling looks alive; one whose walk cycle runs at
|
||||||
a fixed rate while it accelerates looks like it is skating. With a real model
|
a fixed rate while it accelerates looks like it is skating. With a real model
|
||||||
in place of the box you would compare that speed against a couple of
|
in place of the box you would compare that speed against a couple of
|
||||||
thresholds and play an idle, a walk, or a run -- which is lesson
|
thresholds and play an idle, a walk, or a run -- which is lesson
|
||||||
twenty-three's subject, and the manual's example under
|
twenty-three's subject, and the reference's example under
|
||||||
`navAgentGetVelocity` is written for exactly that.
|
`navAgentGetVelocity` is written for exactly that.
|
||||||
|
|
||||||
Which is the real point of this lesson. A character that takes the fast
|
Which is the real point of this lesson. A character that takes the fast
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ hit wins:
|
||||||
|
|
||||||
Inside the database, names are matched without regard to capital letters, with
|
Inside the database, names are matched without regard to capital letters, with
|
||||||
either kind of slash, and relative to the game's root. A leading folder name
|
either kind of slash, and relative to the game's root. A leading folder name
|
||||||
equal to the game's own is ignored, so `MyGame/art/ship.png`, `art/ship.png`,
|
equal to the game's own is ignored, so `MyGame/art/spaceship.png`, `art/spaceship.png`,
|
||||||
and `DIR .. "art/ship.png"` all find the same file.
|
and `DIR .. "art/spaceship.png"` all find the same file.
|
||||||
|
|
||||||
Two rules follow from that, and if you have been following the book you already
|
Two rules follow from that, and if you have been following the book you already
|
||||||
obey both. Use `DIR` for everything you load, so a name never depends on where
|
obey both. Use `DIR` for everything you load, so a name never depends on where
|
||||||
|
|
@ -83,7 +83,7 @@ MyGame.game the packed game
|
||||||
MyGame/ a folder with one changed file in it
|
MyGame/ a folder with one changed file in it
|
||||||
----
|
----
|
||||||
|
|
||||||
Drop a single corrected `art/ship.png` in there and the packed game runs with
|
Drop a single corrected `art/spaceship.png` in there and the packed game runs with
|
||||||
your new picture and everything else out of the database. Nothing has to be
|
your new picture and everything else out of the database. Nothing has to be
|
||||||
repacked to try a change, and you can keep your whole working folder there
|
repacked to try a change, and you can keep your whole working folder there
|
||||||
while you finish the game. It is also how a player mods one, which you may or
|
while you finish the game. It is also how a player mods one, which you may or
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ startsilent = true
|
||||||
`idleexit` quits after that many seconds with no input of any kind, for a
|
`idleexit` quits after that many seconds with no input of any kind, for a
|
||||||
cabinet that should hand the screen back to a menu or a front end.
|
cabinet that should hand the screen back to a menu or a front end.
|
||||||
`startsilent` starts muted until somebody touches something, which is how a
|
`startsilent` starts muted until somebody touches something, which is how a
|
||||||
room full of machines is bearable. There are more; the manual's settings and
|
room full of machines is bearable. There are more; the reference's settings and
|
||||||
command line sections list every one.
|
command line sections list every one.
|
||||||
|
|
||||||
=== Artwork Round the Picture
|
=== Artwork Round the Picture
|
||||||
|
|
@ -631,7 +631,7 @@ the game runs.
|
||||||
. *Play free.* Turn free play on and check that start works with no coins and
|
. *Play free.* Turn free play on and check that start works with no coins and
|
||||||
that credits do not go negative.
|
that credits do not go negative.
|
||||||
. *Add a page.* Put a second page of service information behind button 3 --
|
. *Add a page.* Put a second page of service information behind button 3 --
|
||||||
the fields of `singeGetSystemInfo()` are a good start, and the manual's entry
|
the fields of `singeGetSystemInfo()` are a good start, and the reference's entry
|
||||||
lists all of them.
|
lists all of them.
|
||||||
. *Turn the screen.* Run it with `--rotate=90` and look at your layout. Then
|
. *Turn the screen.* Run it with `--rotate=90` and look at your layout. Then
|
||||||
make the service page readable at that rotation, which is harder than it
|
make the service page readable at that rotation, which is harder than it
|
||||||
|
|
@ -693,7 +693,7 @@ tell when two things touched, how to keep a score somewhere it survives, how
|
||||||
to run film as a world, how to put a model in a room with a light on it, and
|
to run film as a world, how to put a model in a room with a light on it, and
|
||||||
how to hand the whole thing to a stranger.
|
how to hand the whole thing to a stranger.
|
||||||
|
|
||||||
What you need now is the _Singe Manual_, not this book. It is the reference to
|
What you need now is the _Singe Reference_, not this book. It is the reference to
|
||||||
every function, every argument, and every return value, and it is the thing you
|
every function, every argument, and every return value, and it is the thing you
|
||||||
will actually have open while you work. It ships with the engine as
|
will actually have open while you work. It ships with the engine as
|
||||||
`Singe/Manual.pdf`. Read the entry before you use the call; there is a note in
|
`Singe/Manual.pdf`. Read the entry before you use the call; there is a note in
|
||||||
|
|
|
||||||
20
docs/preview/docinfo.html
Normal file
20
docs/preview/docinfo.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!-- The proofreading watermark for the HTML books: ./build-docs.sh preview adds this to every
|
||||||
|
page's head, and a release build never does. Fixed to the window, so it is on every screen of
|
||||||
|
the document, and it lets clicks through. -->
|
||||||
|
<style>
|
||||||
|
body::before {
|
||||||
|
content: "PREVIEW";
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-50deg);
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 22vw;
|
||||||
|
color: #000000;
|
||||||
|
opacity: 0.11;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
310
src/main.c
310
src/main.c
|
|
@ -29,9 +29,11 @@
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// _describeCpu reads the processor name out of the registry, which needs the Windows headers.
|
// _describeCpu reads the processor name out of the registry, which needs the Windows headers;
|
||||||
|
// _relaunch starts the next executable with _spawnv.
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include <io.h>
|
#include <io.h>
|
||||||
|
#include <process.h>
|
||||||
#else
|
#else
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -70,7 +72,8 @@
|
||||||
|
|
||||||
|
|
||||||
#define MENU_OPTIONS "-k -w -v"
|
#define MENU_OPTIONS "-k -w -v"
|
||||||
#define MENU_BINARY_PREFIX "Singe" // The launcher searches for this beside itself; the binary is named Singe-vX.YY-...
|
#define MENU_BINARY_PREFIX "Singe" // The launcher looks beside itself for this, highest version first
|
||||||
|
#define SUPPORT_VERSION_FILE "version.txt" // Inside the Singe folder: which Singe wrote it
|
||||||
#define PRIMARY_DISPLAY 0
|
#define PRIMARY_DISPLAY 0
|
||||||
#define MIXER_FREQUENCY 44100
|
#define MIXER_FREQUENCY 44100
|
||||||
#define MIXER_CHANNELS 2
|
#define MIXER_CHANNELS 2
|
||||||
|
|
@ -131,6 +134,8 @@ typedef enum LongOptionE {
|
||||||
OPT_MAPJOYSTICKS,
|
OPT_MAPJOYSTICKS,
|
||||||
OPT_MONOCHROME,
|
OPT_MONOCHROME,
|
||||||
OPT_NOGAMEPAD,
|
OPT_NOGAMEPAD,
|
||||||
|
OPT_NO_UPDATE_CHECK,
|
||||||
|
OPT_REFRESH,
|
||||||
OPT_SCREEN,
|
OPT_SCREEN,
|
||||||
OPT_SOUNDFONT,
|
OPT_SOUNDFONT,
|
||||||
OPT_STARTSILENT,
|
OPT_STARTSILENT,
|
||||||
|
|
@ -217,6 +222,8 @@ static const OptionT _options[] = {
|
||||||
{ OPT_MAPJOYSTICKS, "mapjoysticks", ap_yes, "BOOLEAN", "write a gamepad mapping for a device SDL does not recognise (the default)", false, true },
|
{ OPT_MAPJOYSTICKS, "mapjoysticks", ap_yes, "BOOLEAN", "write a gamepad mapping for a device SDL does not recognise (the default)", false, true },
|
||||||
{ OPT_MONOCHROME, "monochrome", ap_no, NULL, "start with the disc picture in grey", false, true },
|
{ OPT_MONOCHROME, "monochrome", ap_no, NULL, "start with the disc picture in grey", false, true },
|
||||||
{ OPT_NOGAMEPAD, "nogamepad", ap_no, NULL, "ignore every gamepad, as --nomouse ignores the mice", false, true },
|
{ OPT_NOGAMEPAD, "nogamepad", ap_no, NULL, "ignore every gamepad, as --nomouse ignores the mice", false, true },
|
||||||
|
{ OPT_NO_UPDATE_CHECK, "no_update_check", ap_no, NULL, "never look for a newer Singe from the menu", false, true },
|
||||||
|
{ OPT_REFRESH, "refresh", ap_no, NULL, "write a fresh Singe folder, keeping the old one as old-Singe-vX.YY", false, false },
|
||||||
{ OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true },
|
{ OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true },
|
||||||
{ OPT_SOUNDFONT, "soundfont", ap_yes, "FILE", "synthesise MIDI files with this SoundFont (.sf2)", false, true },
|
{ OPT_SOUNDFONT, "soundfont", ap_yes, "FILE", "synthesise MIDI files with this SoundFont (.sf2)", false, true },
|
||||||
{ OPT_STARTSILENT, "startsilent", ap_no, NULL, "start muted until the first input of any kind", false, true },
|
{ OPT_STARTSILENT, "startsilent", ap_no, NULL, "start muted until the first input of any kind", false, true },
|
||||||
|
|
@ -290,6 +297,7 @@ static bool _parseBoolean(const char *text, bool *value);
|
||||||
static bool _parseFloat(const char *text, double *value);
|
static bool _parseFloat(const char *text, double *value);
|
||||||
static bool _parseGamepadOrder(const char *text);
|
static bool _parseGamepadOrder(const char *text);
|
||||||
static bool _parseInteger(const char *text, int32_t *value);
|
static bool _parseInteger(const char *text, int32_t *value);
|
||||||
|
static bool _relaunch(const char *path, int32_t argc, char *argv[]);
|
||||||
static void _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit);
|
static void _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit);
|
||||||
static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what);
|
static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what);
|
||||||
static void _resolveFiles(const char *exeName, ConfigT *conf);
|
static void _resolveFiles(const char *exeName, ConfigT *conf);
|
||||||
|
|
@ -301,7 +309,63 @@ static void _startSDL(void);
|
||||||
static void _stopSDL(void);
|
static void _stopSDL(void);
|
||||||
static void _stripDatabaseNames(char *path);
|
static void _stripDatabaseNames(char *path);
|
||||||
static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device);
|
static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device);
|
||||||
static void _unpackData(const char *exePath, bool absolute);
|
static void _archiveSupport(void);
|
||||||
|
static void _unpackData(const char *exePath, bool absolute, bool refresh);
|
||||||
|
|
||||||
|
|
||||||
|
// The executable this process was started as, absolute, kept for singeGetSystemInfo and for a
|
||||||
|
// relaunch. main() owns it; everyone else reads it.
|
||||||
|
static char *_executable = NULL;
|
||||||
|
|
||||||
|
|
||||||
|
const char *mainExecutablePath(void) {
|
||||||
|
return _executable;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Starts path in this process's place, with this process's arguments, once everything here has
|
||||||
|
// been torn down. On POSIX that is execv, which never returns when it works. Windows will not
|
||||||
|
// replace a running image, and does not need to: the new Singe has its own name, so it is
|
||||||
|
// started beside this one and this one ends. The file is made executable first, since it
|
||||||
|
// arrived by download. True when the next Singe is running, which on Windows also means this
|
||||||
|
// process must end without waiting for a key: the two would otherwise share one console, with
|
||||||
|
// the old one holding it open until somebody pressed something.
|
||||||
|
static bool _relaunch(const char *path, int32_t argc, char *argv[]) {
|
||||||
|
const char *extra = singeRelaunchExtra();
|
||||||
|
char **args = calloc((size_t)argc + 2, sizeof(char *));
|
||||||
|
int32_t x = 0;
|
||||||
|
#ifdef _WIN32
|
||||||
|
intptr_t child = -1;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (args == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
args[0] = (char *)path;
|
||||||
|
for (x = 1; x < argc; x++) {
|
||||||
|
args[x] = argv[x];
|
||||||
|
}
|
||||||
|
if (extra != NULL) {
|
||||||
|
args[x] = (char *)extra;
|
||||||
|
x++;
|
||||||
|
}
|
||||||
|
args[x] = NULL;
|
||||||
|
utilChMod(path, S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH);
|
||||||
|
#ifdef _WIN32
|
||||||
|
child = _spawnv(_P_NOWAIT, path, (const char *const *)args);
|
||||||
|
if (child != -1) {
|
||||||
|
free(args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
execv(path, args);
|
||||||
|
#endif
|
||||||
|
// Only reached when the start failed; say so and end normally, with the new file left in place.
|
||||||
|
utilSay("Could not start %s.", path);
|
||||||
|
free(args);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Runs one list of options into a configuration: the command line when source is NULL, or one
|
// Runs one list of options into a configuration: the command line when source is NULL, or one
|
||||||
|
|
@ -685,6 +749,16 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons
|
||||||
conf->noGamepad = true;
|
conf->noGamepad = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// Never Ask For A Newer Singe
|
||||||
|
case OPT_NO_UPDATE_CHECK:
|
||||||
|
conf->noUpdateCheck = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// A Fresh Support Folder
|
||||||
|
case OPT_REFRESH:
|
||||||
|
conf->refreshSupport = true;
|
||||||
|
break;
|
||||||
|
|
||||||
// Which Display
|
// Which Display
|
||||||
case OPT_SCREEN:
|
case OPT_SCREEN:
|
||||||
if (!_parseInteger(arg, &conf->screen) || (conf->screen < SCREEN_MIN)) {
|
if (!_parseInteger(arg, &conf->screen) || (conf->screen < SCREEN_MIN)) {
|
||||||
|
|
@ -1733,7 +1807,55 @@ static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static void _unpackData(const char *exePath, bool absolute) {
|
// Puts the support folder aside instead of deleting it: "old-Singe-v3.00", or with a number after
|
||||||
|
// that when the name is taken. Nothing here ever removes anything -- what to keep is the person's
|
||||||
|
// business, and a folder they had edited is exactly the thing not to throw away on their behalf.
|
||||||
|
// The version comes from the marker the folder carries; a folder written before markers existed
|
||||||
|
// is archived as old-Singe-unknown.
|
||||||
|
static void _archiveSupport(void) {
|
||||||
|
char *marker = NULL;
|
||||||
|
char *version = NULL;
|
||||||
|
char *target = NULL;
|
||||||
|
size_t bytes = 0;
|
||||||
|
int32_t n = 0;
|
||||||
|
char *at = NULL;
|
||||||
|
|
||||||
|
if (!utilPathExists(VFS_ENGINE_DIRECTORY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
marker = utilCreateString("%s%c%s", VFS_ENGINE_DIRECTORY, utilGetPathSeparator(), SUPPORT_VERSION_FILE);
|
||||||
|
version = utilReadFile(marker, &bytes);
|
||||||
|
free(marker);
|
||||||
|
if (version == NULL) {
|
||||||
|
version = strdup("unknown");
|
||||||
|
}
|
||||||
|
// The marker is one line; keep what is on it and nothing else.
|
||||||
|
at = strpbrk(version, "\r\n");
|
||||||
|
if (at != NULL) {
|
||||||
|
*at = '\0';
|
||||||
|
}
|
||||||
|
if (version[0] == '\0') {
|
||||||
|
free(version);
|
||||||
|
version = strdup("unknown");
|
||||||
|
}
|
||||||
|
target = utilCreateString("old-%s-%s", VFS_ENGINE_DIRECTORY, version);
|
||||||
|
while (utilPathExists(target) && (n < 100)) {
|
||||||
|
n++;
|
||||||
|
free(target);
|
||||||
|
target = utilCreateString("old-%s-%s-%d", VFS_ENGINE_DIRECTORY, version, n);
|
||||||
|
}
|
||||||
|
if (rename(VFS_ENGINE_DIRECTORY, target) == 0) {
|
||||||
|
_showHeader();
|
||||||
|
utilSay(">>> Kept File: %s (the old %s folder; delete it when you are happy)", target, VFS_ENGINE_DIRECTORY);
|
||||||
|
} else {
|
||||||
|
utilSay("Could not put %s aside as %s; it is being written over instead.", VFS_ENGINE_DIRECTORY, target);
|
||||||
|
}
|
||||||
|
free(target);
|
||||||
|
free(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void _unpackData(const char *exePath, bool absolute, bool refresh) {
|
||||||
const EmbeddedFileT files[] = {
|
const EmbeddedFileT files[] = {
|
||||||
{ "Framework.singe", Framework_singe, Framework_singe_len },
|
{ "Framework.singe", Framework_singe, Framework_singe_len },
|
||||||
{ "controls.cfg.example", controls_cfg, controls_cfg_len },
|
{ "controls.cfg.example", controls_cfg, controls_cfg_len },
|
||||||
|
|
@ -1770,6 +1892,11 @@ static void _unpackData(const char *exePath, bool absolute) {
|
||||||
bool created = false;
|
bool created = false;
|
||||||
|
|
||||||
// Extract missing or outdated support files. We do this here so they are not generated if launched from a front end.
|
// Extract missing or outdated support files. We do this here so they are not generated if launched from a front end.
|
||||||
|
// --refresh puts the whole folder aside first, so what is written is only what this version
|
||||||
|
// ships: an update that stops shipping a file would otherwise leave the old one lying there.
|
||||||
|
if (refresh) {
|
||||||
|
_archiveSupport();
|
||||||
|
}
|
||||||
if (!utilMkDirP(VFS_ENGINE_DIRECTORY, DIRECTORY_MODE)) {
|
if (!utilMkDirP(VFS_ENGINE_DIRECTORY, DIRECTORY_MODE)) {
|
||||||
utilDie("Unable to create %s directory.", VFS_ENGINE_DIRECTORY);
|
utilDie("Unable to create %s directory.", VFS_ENGINE_DIRECTORY);
|
||||||
}
|
}
|
||||||
|
|
@ -1780,6 +1907,14 @@ static void _unpackData(const char *exePath, bool absolute) {
|
||||||
free(temp);
|
free(temp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which Singe wrote this folder, so --refresh can name what it puts aside and a person can tell
|
||||||
|
// at a glance. Written like the rest, so it only changes when the version does.
|
||||||
|
temp = utilCreateString("%s%c%s", VFS_ENGINE_DIRECTORY, utilGetPathSeparator(), SUPPORT_VERSION_FILE);
|
||||||
|
data = utilCreateString("%s\n", VERSION_STRING);
|
||||||
|
created |= _extractFile(temp, (const uint8_t *)data, strlen(data));
|
||||||
|
free(data);
|
||||||
|
free(temp);
|
||||||
|
|
||||||
// Script to start menu system. The launcher finds the binary beside itself rather than naming
|
// Script to start menu system. The launcher finds the binary beside itself rather than naming
|
||||||
// it, because the binary carries its version and a named one stops working at the next release;
|
// it, because the binary carries its version and a named one stops working at the next release;
|
||||||
// the game launchers in the wild do the same. With --gamedir the binary is elsewhere, so there
|
// the game launchers in the wild do the same. With --gamedir the binary is elsewhere, so there
|
||||||
|
|
@ -1790,7 +1925,73 @@ static void _unpackData(const char *exePath, bool absolute) {
|
||||||
if (absolute) {
|
if (absolute) {
|
||||||
data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s %s/menuBackground.mkv %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s %s/menuBackground.mkv %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
||||||
} else {
|
} else {
|
||||||
data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\nSINGE=\nfor f in ./%s*; do\n if [[ -x \"$f\" ]] && [[ -f \"$f\" ]]; then\n SINGE=\"$f\" && break\n fi\ndone\nif [[ -z \"$SINGE\" ]]; then\n echo \"Cannot find the %s program beside this script.\"\n exit 1\nfi\n\"${SINGE}\" %s %s/menuBackground.mkv %s/Menu.singe\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
// The best binary beside the script, not the first name the shell happens to list: a
|
||||||
|
// folder holding both 3.00 and a beta sorts differently under different locales, and a
|
||||||
|
// person who unpacked a beta over a release would otherwise start whichever the
|
||||||
|
// collation preferred. The key is this machine's architecture first, then the version
|
||||||
|
// the master service ranks by -- major, minor, then a beta below the release of that
|
||||||
|
// number, then which beta -- built as fixed-width digits so a plain string compare does
|
||||||
|
// the right thing. Architecture outranks version because a newer build for another
|
||||||
|
// machine cannot run at all, and one game folder shared between, say, a Pi and a PC
|
||||||
|
// holds a binary for each; it is decided here rather than baked in, since whichever
|
||||||
|
// machine wrote this folder is not necessarily the one reading it. A name for neither
|
||||||
|
// this architecture nor any other we can read still gets its turn when nothing better
|
||||||
|
// is there, because a real complaint from the binary beats "cannot find Singe".
|
||||||
|
// Anything put aside as "old-..." is never matched by the pattern, so it stays out of
|
||||||
|
// the running. 10# forces base ten: a minor written "09" is not octal, whatever
|
||||||
|
// printf thinks.
|
||||||
|
data = utilCreateString(
|
||||||
|
"#!/usr/bin/env bash\n"
|
||||||
|
"\n"
|
||||||
|
"cd \"$(dirname \"$0\")\"\n"
|
||||||
|
"os=$(uname -s)\n"
|
||||||
|
"cpu=$(uname -m)\n"
|
||||||
|
"case $os in\n"
|
||||||
|
" Darwin) os=Macos ;;\n"
|
||||||
|
"esac\n"
|
||||||
|
"case $cpu in\n"
|
||||||
|
" amd64) cpu=x86_64 ;;\n"
|
||||||
|
" arm64) cpu=aarch64 ;;\n"
|
||||||
|
"esac\n"
|
||||||
|
"mine() {\n"
|
||||||
|
" local n=\"${1##*/}\"\n"
|
||||||
|
" n=\"${n%%.exe}\"\n"
|
||||||
|
" if [[ $n == *-\"$os-$cpu\" ]]; then\n"
|
||||||
|
" printf '1'\n"
|
||||||
|
" elif [[ $os == Macos && $n == *-Macos-universal ]]; then\n"
|
||||||
|
" printf '1'\n"
|
||||||
|
" else\n"
|
||||||
|
" printf '0'\n"
|
||||||
|
" fi\n"
|
||||||
|
"}\n"
|
||||||
|
"key() {\n"
|
||||||
|
" local n=\"${1##*/}\"\n"
|
||||||
|
" n=\"${n%%.exe}\"\n"
|
||||||
|
" if [[ $n =~ ^%s-v([0-9]+)\\.([0-9]+)b([0-9]+)- ]]; then\n"
|
||||||
|
" printf '%%05d%%05d0%%05d' $((10#${BASH_REMATCH[1]})) $((10#${BASH_REMATCH[2]})) $((10#${BASH_REMATCH[3]}))\n"
|
||||||
|
" elif [[ $n =~ ^%s-v([0-9]+)\\.([0-9]+)- ]]; then\n"
|
||||||
|
" printf '%%05d%%05d1%%05d' $((10#${BASH_REMATCH[1]})) $((10#${BASH_REMATCH[2]})) 0\n"
|
||||||
|
" else\n"
|
||||||
|
" printf '0000000000000000'\n"
|
||||||
|
" fi\n"
|
||||||
|
"}\n"
|
||||||
|
"SINGE=\n"
|
||||||
|
"BEST=\n"
|
||||||
|
"for f in ./%s*; do\n"
|
||||||
|
" [[ -x \"$f\" && -f \"$f\" ]] || continue\n"
|
||||||
|
" k=$(mine \"$f\")$(key \"$f\")\n"
|
||||||
|
" if [[ -z $SINGE || $k > $BEST ]]; then\n"
|
||||||
|
" SINGE=\"$f\"\n"
|
||||||
|
" BEST=$k\n"
|
||||||
|
" fi\n"
|
||||||
|
"done\n"
|
||||||
|
"if [[ -z \"$SINGE\" ]]; then\n"
|
||||||
|
" echo \"Cannot find the %s program beside this script.\"\n"
|
||||||
|
" exit 1\n"
|
||||||
|
"fi\n"
|
||||||
|
"\"${SINGE}\" %s %s/menuBackground.mkv %s/Menu.singe\n",
|
||||||
|
MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_BINARY_PREFIX,
|
||||||
|
MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Winders
|
// Winders
|
||||||
|
|
@ -1798,7 +1999,86 @@ static void _unpackData(const char *exePath, bool absolute) {
|
||||||
if (absolute) {
|
if (absolute) {
|
||||||
data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nstart \"\" \"%s\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nstart \"\" \"%s\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
||||||
} else {
|
} else {
|
||||||
data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nset \"SINGE=\"\r\nfor /f \"tokens=* usebackq\" %%%%f in (`dir /b %s*.exe`) do (set \"SINGE=%%%%f\" & goto :next)\r\n:next\r\nif not defined SINGE (\r\n echo Cannot find the %s program beside this script.\r\n pause\r\n exit /b 1\r\n)\r\nstart \"\" \"%%SINGE%%\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
|
// The best binary beside the script, as Menu.sh picks it, and for the same reasons:
|
||||||
|
// a folder holding a release and a beta must start the release, and "dir" hands them
|
||||||
|
// over in the directory's own order, which put 3.09 ahead of 3.10 and started the
|
||||||
|
// older one. The key is ten digits -- 1 for a build this machine can run and 0 for
|
||||||
|
// one it cannot, then a leading 1, the major and minor in two each, then 1 for a
|
||||||
|
// release or 0 for a beta, then which beta in three -- so the architecture decides
|
||||||
|
// first, the version decides after it, and the whole thing still fits in the 32 bits
|
||||||
|
// cmd compares numbers in and reads the same as a string either way. The architecture
|
||||||
|
// comes from the environment rather than being baked in, because one folder on a share
|
||||||
|
// can be read by more than one machine. The name's own last two pieces are compared
|
||||||
|
// whole, since "Windows-x86" is the front of "Windows-x86_64" and a 32-bit Windows
|
||||||
|
// would otherwise think a 64-bit build was its own. ARCHITEW6432 is there because a
|
||||||
|
// 32-bit cmd on
|
||||||
|
// a 64-bit Windows reports the emulated architecture in the usual variable and the real
|
||||||
|
// one in that. Anything set aside as "old-..." is never matched by the pattern, so it
|
||||||
|
// stays out of the running. Zeroes are trimmed by taking the last digits of a padded
|
||||||
|
// string rather than by arithmetic, because "set /a" reads a leading zero as octal and
|
||||||
|
// 3.09 would stop the launcher dead.
|
||||||
|
data = utilCreateString(
|
||||||
|
"@echo off\r\n"
|
||||||
|
"cd /d \"%%~dp0\"\r\n"
|
||||||
|
"setlocal enabledelayedexpansion\r\n"
|
||||||
|
"set \"CPU=%%PROCESSOR_ARCHITECTURE%%\"\r\n"
|
||||||
|
"if defined PROCESSOR_ARCHITEW6432 set \"CPU=%%PROCESSOR_ARCHITEW6432%%\"\r\n"
|
||||||
|
"if /i \"%%CPU%%\"==\"AMD64\" set \"CPU=x86_64\"\r\n"
|
||||||
|
"if /i \"%%CPU%%\"==\"ARM64\" set \"CPU=aarch64\"\r\n"
|
||||||
|
"set \"PLAT=Windows-%%CPU%%\"\r\n"
|
||||||
|
"set \"SINGE=\"\r\n"
|
||||||
|
"set \"BEST=\"\r\n"
|
||||||
|
"for /f \"delims=\" %%%%f in ('dir /b /a-d %s*.exe 2^>nul') do (\r\n"
|
||||||
|
" call :key \"%%%%~nf\"\r\n"
|
||||||
|
" if not defined SINGE (\r\n"
|
||||||
|
" set \"SINGE=%%%%f\"\r\n"
|
||||||
|
" set \"BEST=!KEY!\"\r\n"
|
||||||
|
" ) else if !KEY! GTR !BEST! (\r\n"
|
||||||
|
" set \"SINGE=%%%%f\"\r\n"
|
||||||
|
" set \"BEST=!KEY!\"\r\n"
|
||||||
|
" )\r\n"
|
||||||
|
")\r\n"
|
||||||
|
"if not defined SINGE (\r\n"
|
||||||
|
" echo Cannot find the %s program beside this script.\r\n"
|
||||||
|
" pause\r\n"
|
||||||
|
" exit /b 1\r\n"
|
||||||
|
")\r\n"
|
||||||
|
"start \"\" \"!SINGE!\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n"
|
||||||
|
"exit /b 0\r\n"
|
||||||
|
"\r\n"
|
||||||
|
":key\r\n"
|
||||||
|
"set \"n=%%~1\"\r\n"
|
||||||
|
"set \"MINE=0\"\r\n"
|
||||||
|
"for /f \"tokens=3,4 delims=-\" %%%%a in (\"!n!\") do (\r\n"
|
||||||
|
" if /i \"%%%%a-%%%%b\"==\"!PLAT!\" set \"MINE=1\"\r\n"
|
||||||
|
")\r\n"
|
||||||
|
"set \"KEY=!MINE!000000000\"\r\n"
|
||||||
|
"set \"v=!n:%s-v=!\"\r\n"
|
||||||
|
"if \"!v!\"==\"!n!\" exit /b\r\n"
|
||||||
|
"for /f \"tokens=1 delims=-\" %%%%a in (\"!v!\") do set \"v=%%%%a\"\r\n"
|
||||||
|
"set \"maj=\"\r\n"
|
||||||
|
"set \"min=\"\r\n"
|
||||||
|
"for /f \"tokens=1,2 delims=.\" %%%%a in (\"!v!\") do (\r\n"
|
||||||
|
" set \"maj=%%%%a\"\r\n"
|
||||||
|
" set \"min=%%%%b\"\r\n"
|
||||||
|
")\r\n"
|
||||||
|
"if not defined min exit /b\r\n"
|
||||||
|
"set \"rel=1\"\r\n"
|
||||||
|
"set \"bet=0\"\r\n"
|
||||||
|
"set \"trimmed=!min:b=!\"\r\n"
|
||||||
|
"if not \"!trimmed!\"==\"!min!\" (\r\n"
|
||||||
|
" set \"rel=0\"\r\n"
|
||||||
|
" for /f \"tokens=1,2 delims=b\" %%%%a in (\"!min!\") do (\r\n"
|
||||||
|
" set \"min=%%%%a\"\r\n"
|
||||||
|
" set \"bet=%%%%b\"\r\n"
|
||||||
|
" )\r\n"
|
||||||
|
")\r\n"
|
||||||
|
"set \"maj=00!maj!\"\r\n"
|
||||||
|
"set \"min=00!min!\"\r\n"
|
||||||
|
"set \"bet=000!bet!\"\r\n"
|
||||||
|
"set \"KEY=!MINE!1!maj:~-2!!min:~-2!!rel!!bet:~-3!\"\r\n"
|
||||||
|
"exit /b\r\n",
|
||||||
|
MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY, MENU_BINARY_PREFIX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
created |= _extractFile(temp, (const uint8_t *)data, strlen(data));
|
created |= _extractFile(temp, (const uint8_t *)data, strlen(data));
|
||||||
|
|
@ -2093,6 +2373,7 @@ int main(int argc, char *argv[]) {
|
||||||
ConfigT *replacement = NULL;
|
ConfigT *replacement = NULL;
|
||||||
QueueT *q = NULL;
|
QueueT *q = NULL;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
bool relaunched = false;
|
||||||
|
|
||||||
#ifndef _WIN32
|
#ifndef _WIN32
|
||||||
signal(SIGSEGV, _crashHandler);
|
signal(SIGSEGV, _crashHandler);
|
||||||
|
|
@ -2125,8 +2406,8 @@ int main(int argc, char *argv[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_unpackData(exePath, conf->gameDir != NULL);
|
_unpackData(exePath, conf->gameDir != NULL, conf->refreshSupport);
|
||||||
free(exePath);
|
_executable = exePath;
|
||||||
|
|
||||||
// -d names the base under which every game gets a data directory; without it, data/ in the
|
// -d names the base under which every game gets a data directory; without it, data/ in the
|
||||||
// working directory serves (2.x wrote beside the game, which may not be writable).
|
// working directory serves (2.x wrote beside the game, which may not be writable).
|
||||||
|
|
@ -2198,7 +2479,18 @@ int main(int argc, char *argv[]) {
|
||||||
free(_commandLine);
|
free(_commandLine);
|
||||||
free(_settingsSummary);
|
free(_settingsSummary);
|
||||||
|
|
||||||
if (utilGetConsoleEnabled()) {
|
// A script asked for another executable to take over -- the menu's Engine Update, having put
|
||||||
|
// a newer Singe beside this one. Everything above has been torn down, so what starts now
|
||||||
|
// starts as clean as a fresh launch, with the same arguments.
|
||||||
|
if (singeRelaunchPath() != NULL) {
|
||||||
|
relaunched = _relaunch(singeRelaunchPath(), argc, argv);
|
||||||
|
}
|
||||||
|
free(_executable);
|
||||||
|
|
||||||
|
// A key wait here would leave this process holding the console the Singe it just started is
|
||||||
|
// writing to, so it only happens when nothing took over. A relaunch that failed said so
|
||||||
|
// above and is worth reading, so that still waits.
|
||||||
|
if (utilGetConsoleEnabled() && !relaunched) {
|
||||||
utilWaitForKeyOnWindows();
|
utilWaitForKeyOnWindows();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ bool isFrameFileName(const char *filename);
|
||||||
char *mainDescribeAudioDecoders(void);
|
char *mainDescribeAudioDecoders(void);
|
||||||
char *mainDescribeCpu(void);
|
char *mainDescribeCpu(void);
|
||||||
char *mainDescribeOs(void);
|
char *mainDescribeOs(void);
|
||||||
|
const char *mainExecutablePath(void);
|
||||||
bool parseSindenString(const char *sindenString, ConfigT *conf);
|
bool parseSindenString(const char *sindenString, ConfigT *conf);
|
||||||
void queueScript(const ConfigT *conf);
|
void queueScript(const ConfigT *conf);
|
||||||
char *resolveDataDir(const ConfigT *conf);
|
char *resolveDataDir(const ConfigT *conf);
|
||||||
|
|
|
||||||
63
src/pack.c
63
src/pack.c
|
|
@ -68,6 +68,9 @@ typedef struct WriterS {
|
||||||
} WriterT;
|
} WriterT;
|
||||||
|
|
||||||
|
|
||||||
|
// Below this, rebuilding the file costs more than the space is worth.
|
||||||
|
#define PACK_RECLAIM_MINIMUM (1024 * 1024)
|
||||||
|
|
||||||
static const char *const _badFilenames[] = { "controls.dat", "Framework.singe", NULL };
|
static const char *const _badFilenames[] = { "controls.dat", "Framework.singe", NULL };
|
||||||
static const char *const _badExtensions[] = { "exe", "sh", "bat", "cmd", NULL };
|
static const char *const _badExtensions[] = { "exe", "sh", "bat", "cmd", NULL };
|
||||||
static const char *const _textExtensions[] = { "singe", "lua", "dat", "cfg", "txt", NULL };
|
static const char *const _textExtensions[] = { "singe", "lua", "dat", "cfg", "txt", NULL };
|
||||||
|
|
@ -91,6 +94,7 @@ static bool _scanEscapes(const EntryT *list);
|
||||||
static bool _validateGamesDat(WriterT *writer);
|
static bool _validateGamesDat(WriterT *writer);
|
||||||
static bool _writeBlob(FILE *file, const void *data, size_t bytes);
|
static bool _writeBlob(FILE *file, const void *data, size_t bytes);
|
||||||
static bool _writeMeta(sqlite3 *db, const char *key, const char *value);
|
static bool _writeMeta(sqlite3 *db, const char *key, const char *value);
|
||||||
|
static int64_t _reclaim(const char *database);
|
||||||
static bool _writerClose(WriterT *writer, bool commit);
|
static bool _writerClose(WriterT *writer, bool commit);
|
||||||
static bool _writerOpen(WriterT *writer, const char *database, bool create);
|
static bool _writerOpen(WriterT *writer, const char *database, bool create);
|
||||||
|
|
||||||
|
|
@ -577,6 +581,54 @@ static bool _writeMeta(sqlite3 *db, const char *key, const char *value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Gives a patched game back the space its old contents were using. SQLite keeps a freed page for
|
||||||
|
// its own reuse rather than handing it to the filesystem, which is right for a database being
|
||||||
|
// written all day and wrong for a game somebody is about to ship: replace a 300 MB video with a
|
||||||
|
// 30 MB one and the file stays 300 MB for ever. VACUUM rebuilds it. Only worth the rebuild when
|
||||||
|
// there is something to gain, so a patch that swapped like for like does not pay for one, and the
|
||||||
|
// bytes recovered are reported because a file changing size on its own should say why.
|
||||||
|
//
|
||||||
|
// Returns the bytes recovered, 0 when nothing was worth doing, or -1 when the rebuild failed --
|
||||||
|
// which loses nothing: the patched game is already correct, just larger than it needs to be.
|
||||||
|
static int64_t _reclaim(const char *database) {
|
||||||
|
sqlite3 *db = NULL;
|
||||||
|
sqlite3_stmt *stmt = NULL;
|
||||||
|
int64_t free = 0;
|
||||||
|
int64_t page = 0;
|
||||||
|
int64_t was = 0;
|
||||||
|
bool ok = true;
|
||||||
|
|
||||||
|
if (sqlite3_open(database, &db) != SQLITE_OK) {
|
||||||
|
sqlite3_close(db);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (sqlite3_prepare_v2(db, "PRAGMA freelist_count", -1, &stmt, NULL) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
free = sqlite3_column_int64(stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
if (sqlite3_prepare_v2(db, "PRAGMA page_size", -1, &stmt, NULL) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
page = sqlite3_column_int64(stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
was = free * page;
|
||||||
|
if (was < PACK_RECLAIM_MINIMUM) {
|
||||||
|
sqlite3_close(db);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
ok = _exec(db, "VACUUM");
|
||||||
|
sqlite3_close(db);
|
||||||
|
if (!ok) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return was;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static bool _writerClose(WriterT *writer, bool commit) {
|
static bool _writerClose(WriterT *writer, bool commit) {
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
|
|
||||||
|
|
@ -781,6 +833,17 @@ bool packPatch(const char *database, const char *source) {
|
||||||
if (!_writerClose(&writer, ok)) {
|
if (!_writerClose(&writer, ok)) {
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
|
// Once the writer has let go of the file: a patch that replaced something big with something
|
||||||
|
// small leaves the space behind, and a game about to be shipped should not carry it.
|
||||||
|
if (ok) {
|
||||||
|
int64_t recovered = _reclaim(database);
|
||||||
|
|
||||||
|
if (recovered > 0) {
|
||||||
|
utilSay(">>> Recovered %" PRId64 " bytes of space the old contents were using", recovered);
|
||||||
|
} else if (recovered < 0) {
|
||||||
|
utilSay("!!! Could not recover the space the old contents were using; %s is correct but larger than it needs to be.", database);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
69
src/singe.c
69
src/singe.c
|
|
@ -67,6 +67,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L);
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "midi.h"
|
#include "midi.h"
|
||||||
#include "midiIo.h"
|
#include "midiIo.h"
|
||||||
|
#include "pack.h"
|
||||||
#include "util.h"
|
#include "util.h"
|
||||||
#include "frameFile.h"
|
#include "frameFile.h"
|
||||||
#include "vfs.h"
|
#include "vfs.h"
|
||||||
|
|
@ -561,6 +562,8 @@ typedef struct GlobalS {
|
||||||
SDL_Renderer *renderer;
|
SDL_Renderer *renderer;
|
||||||
SDL_GPUDevice *device;
|
SDL_GPUDevice *device;
|
||||||
bool reloadRequested; // singeReload, F5 or a watched file changing
|
bool reloadRequested; // singeReload, F5 or a watched file changing
|
||||||
|
char *relaunchPath; // singeRelaunch: the executable to start once this one has ended
|
||||||
|
char *relaunchExtra; // and one argument added to the ones this run was given
|
||||||
WatchedFileT *watched;
|
WatchedFileT *watched;
|
||||||
int32_t watchedCount;
|
int32_t watchedCount;
|
||||||
uint64_t watchTick; // When the watched files were last checked
|
uint64_t watchTick; // When the watched files were last checked
|
||||||
|
|
@ -1371,7 +1374,9 @@ static int32_t apiSingeGetScriptPath(lua_State *L);
|
||||||
static int32_t apiSingeGetTicks(lua_State *L);
|
static int32_t apiSingeGetTicks(lua_State *L);
|
||||||
static int32_t apiSingeGetWidth(lua_State *L);
|
static int32_t apiSingeGetWidth(lua_State *L);
|
||||||
static int32_t apiSingeHasGpu(lua_State *L);
|
static int32_t apiSingeHasGpu(lua_State *L);
|
||||||
|
static int32_t apiSingePack(lua_State *L);
|
||||||
static int32_t apiSingeQuit(lua_State *L);
|
static int32_t apiSingeQuit(lua_State *L);
|
||||||
|
static int32_t apiSingeRelaunch(lua_State *L);
|
||||||
static int32_t apiSingeReload(lua_State *L);
|
static int32_t apiSingeReload(lua_State *L);
|
||||||
static int32_t apiSingeScreenshot(lua_State *L);
|
static int32_t apiSingeScreenshot(lua_State *L);
|
||||||
static int32_t apiSingeSetAudioCalibration(lua_State *L);
|
static int32_t apiSingeSetAudioCalibration(lua_State *L);
|
||||||
|
|
@ -5786,6 +5791,8 @@ static void _pushConstants(lua_State *L) {
|
||||||
lua_setglobal(L, "SINGE_LEGACY_SPRITE_ARGS");
|
lua_setglobal(L, "SINGE_LEGACY_SPRITE_ARGS");
|
||||||
lua_pushboolean(L, _global.conf->disc);
|
lua_pushboolean(L, _global.conf->disc);
|
||||||
lua_setglobal(L, "SINGE_DISC");
|
lua_setglobal(L, "SINGE_DISC");
|
||||||
|
lua_pushboolean(L, !_global.conf->noUpdateCheck);
|
||||||
|
lua_setglobal(L, "SINGE_UPDATE_CHECK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -6242,7 +6249,9 @@ static void _registerApi(lua_State *L) {
|
||||||
lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00
|
lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00
|
||||||
lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx
|
lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx
|
||||||
lua_register(L, "singeHasGpu", apiSingeHasGpu); // 3.00
|
lua_register(L, "singeHasGpu", apiSingeHasGpu); // 3.00
|
||||||
|
lua_register(L, "singePack", apiSingePack); // 3.00
|
||||||
lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG
|
lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG
|
||||||
|
lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00
|
||||||
lua_register(L, "singeReload", apiSingeReload); // 3.00
|
lua_register(L, "singeReload", apiSingeReload); // 3.00
|
||||||
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
|
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
|
||||||
lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx
|
lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx
|
||||||
|
|
@ -13516,6 +13525,8 @@ static int32_t apiSingeGetSystemInfo(lua_State *L) {
|
||||||
lua_newtable(L);
|
lua_newtable(L);
|
||||||
lua_pushstring(L, VERSION_STRING); lua_setfield(L, -2, "version");
|
lua_pushstring(L, VERSION_STRING); lua_setfield(L, -2, "version");
|
||||||
lua_pushstring(L, (os != NULL) ? os : ""); lua_setfield(L, -2, "os");
|
lua_pushstring(L, (os != NULL) ? os : ""); lua_setfield(L, -2, "os");
|
||||||
|
lua_pushstring(L, SINGE_PLATFORM); lua_setfield(L, -2, "platform");
|
||||||
|
lua_pushstring(L, (mainExecutablePath() != NULL) ? mainExecutablePath() : ""); lua_setfield(L, -2, "executable");
|
||||||
lua_pushstring(L, (cpu != NULL) ? cpu : ""); lua_setfield(L, -2, "cpu");
|
lua_pushstring(L, (cpu != NULL) ? cpu : ""); lua_setfield(L, -2, "cpu");
|
||||||
lua_pushstring(L, SDL_GetRendererName(_global.renderer)); lua_setfield(L, -2, "renderer");
|
lua_pushstring(L, SDL_GetRendererName(_global.renderer)); lua_setfield(L, -2, "renderer");
|
||||||
lua_pushstring(L, (_global.device != NULL) ? rgpuGetDeviceDriver(_global.device) : "none (3D unavailable)"); lua_setfield(L, -2, "gpu");
|
lua_pushstring(L, (_global.device != NULL) ? rgpuGetDeviceDriver(_global.device) : "none (3D unavailable)"); lua_setfield(L, -2, "gpu");
|
||||||
|
|
@ -13642,6 +13653,26 @@ static int32_t apiSingeHasGpu(lua_State *L) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// result = singePack(directory, database)
|
||||||
|
//
|
||||||
|
// The packer that --pack runs, from a script: what lets an authoring tool release a game without
|
||||||
|
// leaving the engine. The reasons for a refusal are printed as they are for the command line.
|
||||||
|
static int32_t apiSingePack(lua_State *L) {
|
||||||
|
const char *directory = NULL;
|
||||||
|
const char *database = NULL;
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
_argCheck(L, "singePack", 2, 2);
|
||||||
|
directory = _argString(L, "singePack", 1);
|
||||||
|
database = _argString(L, "singePack", 2);
|
||||||
|
ok = packGame(directory, database);
|
||||||
|
_luaTrace(L, "singePack", "%s %s %s", directory, database, ok ? "ok" : "failed");
|
||||||
|
lua_pushboolean(L, ok);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static int32_t apiSingeQuit(lua_State *L) {
|
static int32_t apiSingeQuit(lua_State *L) {
|
||||||
_luaTrace(L, "singeQuit", "Quit requested.");
|
_luaTrace(L, "singeQuit", "Quit requested.");
|
||||||
_global.running = false;
|
_global.running = false;
|
||||||
|
|
@ -13650,6 +13681,44 @@ static int32_t apiSingeQuit(lua_State *L) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// result = singeRelaunch(path [, extra])
|
||||||
|
//
|
||||||
|
// Ends the engine as singeQuit does and, once everything has been torn down, starts path in its
|
||||||
|
// place with the same arguments, plus extra when one is given. For the menu's Engine Update,
|
||||||
|
// which has put a newer Singe beside this one and asks it for a fresh support folder. The file
|
||||||
|
// has to exist; it is made executable when the moment comes.
|
||||||
|
const char *singeRelaunchExtra(void) {
|
||||||
|
return _global.relaunchExtra;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const char *singeRelaunchPath(void) {
|
||||||
|
return _global.relaunchPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static int32_t apiSingeRelaunch(lua_State *L) {
|
||||||
|
const char *path = NULL;
|
||||||
|
|
||||||
|
_argCheck(L, "singeRelaunch", 1, 2);
|
||||||
|
path = _argString(L, "singeRelaunch", 1);
|
||||||
|
free(_global.relaunchExtra);
|
||||||
|
_global.relaunchExtra = (lua_gettop(L) >= 2) ? strdup(_argString(L, "singeRelaunch", 2)) : NULL;
|
||||||
|
if (!utilFileExists(path)) {
|
||||||
|
_luaTrace(L, "singeRelaunch", "%s does not exist", path);
|
||||||
|
lua_pushboolean(L, false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
free(_global.relaunchPath);
|
||||||
|
_global.relaunchPath = strdup(path);
|
||||||
|
_global.running = false;
|
||||||
|
_luaTrace(L, "singeRelaunch", "%s", path);
|
||||||
|
lua_pushboolean(L, true);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// singeReload(): runs the game again from its script at the end of this frame
|
// singeReload(): runs the game again from its script at the end of this frame
|
||||||
static int32_t apiSingeReload(lua_State *L) {
|
static int32_t apiSingeReload(lua_State *L) {
|
||||||
_luaTrace(L, "singeReload", "Reload requested.");
|
_luaTrace(L, "singeReload", "Reload requested.");
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,8 @@ typedef struct ConfigS {
|
||||||
bool disc; // Play a laserdisc video; otherwise the canvas is the world
|
bool disc; // Play a laserdisc video; otherwise the canvas is the world
|
||||||
bool softwareVideo; // Skip the platform's hardware decoder
|
bool softwareVideo; // Skip the platform's hardware decoder
|
||||||
bool noGamepad; // --nogamepad: ignore every gamepad, as --nomouse ignores the mice
|
bool noGamepad; // --nogamepad: ignore every gamepad, as --nomouse ignores the mice
|
||||||
|
bool noUpdateCheck; // --no_update_check: the menu never asks the service for a newer Singe
|
||||||
|
bool refreshSupport; // --refresh: put the Singe folder aside as old-Singe-vX.YY and write a fresh one
|
||||||
bool mapJoysticks; // --mapjoysticks: write a gamepad mapping for a device SDL does not recognise
|
bool mapJoysticks; // --mapjoysticks: write a gamepad mapping for a device SDL does not recognise
|
||||||
bool joyMouse; // --joymouse: the first gamepad's left stick drives the mouse cursor
|
bool joyMouse; // --joymouse: the first gamepad's left stick drives the mouse cursor
|
||||||
bool manyMouse; // --manymouse: the user asks for one mouse device per player, whatever the game asks for
|
bool manyMouse; // --manymouse: the user asks for one mouse device per player, whatever the game asks for
|
||||||
|
|
@ -163,6 +165,8 @@ ConfigT *confFromGamesDat(const ConfigT *conf);
|
||||||
void settingsFree(SettingT *settings, int32_t count);
|
void settingsFree(SettingT *settings, int32_t count);
|
||||||
SettingT *settingsLoad(const ConfigT *conf, int32_t *count);
|
SettingT *settingsLoad(const ConfigT *conf, int32_t *count);
|
||||||
void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf);
|
void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf);
|
||||||
|
const char *singeRelaunchExtra(void);
|
||||||
|
const char *singeRelaunchPath(void);
|
||||||
|
|
||||||
|
|
||||||
#endif // SINGE_H
|
#endif // SINGE_H
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,14 @@
|
||||||
// ===== THIS FILE IS AUTOMATICALLY GENERATED FROM version.h.in - DO NOT EDIT =====
|
// ===== THIS FILE IS AUTOMATICALLY GENERATED FROM version.h.in - DO NOT EDIT =====
|
||||||
// The version number lives in the project() line of CMakeLists.txt.
|
// The version number lives in the project() line of CMakeLists.txt.
|
||||||
|
|
||||||
#define SINGE_VERSION @PROJECT_VERSION@
|
// SINGE_VERSION is a number, because scripts are handed it as one, and it is the major and minor
|
||||||
|
// alone: a beta of 3.00 is still 3.00 to anything doing arithmetic. VERSION_STRING is what
|
||||||
|
// people and the master service see, and it says which beta: "v3.00b2".
|
||||||
|
#define SINGE_VERSION @PROJECT_VERSION_MAJOR@.@PROJECT_VERSION_MINOR@
|
||||||
#define SINGE_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
|
#define SINGE_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
|
||||||
#define SINGE_VERSION_MINOR @PROJECT_VERSION_MINOR@
|
#define SINGE_VERSION_MINOR @PROJECT_VERSION_MINOR@
|
||||||
#define VERSION_STRING "v@PROJECT_VERSION@"
|
#define SINGE_VERSION_BETA @PROJECT_VERSION_TWEAK@ // 0 unless this is a beta
|
||||||
|
#define VERSION_STRING "v@SINGE_VERSION_FULL@"
|
||||||
#define COPYRIGHT_END_YEAR "@SINGE_COPYRIGHT_END_YEAR@"
|
#define COPYRIGHT_END_YEAR "@SINGE_COPYRIGHT_END_YEAR@"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
37
testScripts/author/picture.forge
Normal file
37
testScripts/author/picture.forge
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- The pictureAt condition: what the disc's picture is at a point. One watcher counts the frames
|
||||||
|
-- the spot reads dark and the frames it reads bright, so a run with no video proves the documented
|
||||||
|
-- answer (dark everywhere, since the engine reads zeroes when nothing is playing) and a run with
|
||||||
|
-- --framefile proves the condition sees the picture change.
|
||||||
|
return {
|
||||||
|
title = "Author test: the picture at a point",
|
||||||
|
players = 1,
|
||||||
|
size = { w = 720, h = 480 },
|
||||||
|
layers = { { kind = "overlay" } },
|
||||||
|
vars = { dark = 0, bright = 0, neither = 0 },
|
||||||
|
|
||||||
|
types = {
|
||||||
|
watcher = { look = { kind = "none" } }
|
||||||
|
},
|
||||||
|
|
||||||
|
rooms = {
|
||||||
|
{ name = "only", reset = true,
|
||||||
|
entities = {
|
||||||
|
{ id = "eye", type = "watcher", x = 8, y = 8 }
|
||||||
|
} }
|
||||||
|
},
|
||||||
|
|
||||||
|
rules = {
|
||||||
|
{ note = "The spot is dark",
|
||||||
|
on = "frame", each = "watcher",
|
||||||
|
when = { { "pictureAt", x = 200, y = 120, is = "dark" } },
|
||||||
|
act = { { "addVar", name = "dark", amount = 1 } } },
|
||||||
|
{ note = "The spot is bright",
|
||||||
|
on = "frame", each = "watcher",
|
||||||
|
when = { { "pictureAt", x = 200, y = 120, is = "bright" } },
|
||||||
|
act = { { "addVar", name = "bright", amount = 1 } } },
|
||||||
|
{ note = "The spot is neither",
|
||||||
|
on = "frame", each = "watcher",
|
||||||
|
when = { { "pictureAt", x = 200, y = 120, is = "unknown" } },
|
||||||
|
act = { { "addVar", name = "neither", amount = 1 } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
89
testScripts/scene100.singe
Normal file
89
testScripts/scene100.singe
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
-- Forge: the picture at a point, and the light sensor that judges a shot by it. pictureAt is the
|
||||||
|
-- condition a rule asks -- bright, dark, or neither -- and lightSensor is the behaviour built on
|
||||||
|
-- the same read, the ActionMax way of judging a shot over video. Both are the framework's own
|
||||||
|
-- words, so this scene is what covers them from the repository alone: the description compiles,
|
||||||
|
-- the emitted Lua calls the runtime, and the answers are the documented ones. Run plain, where
|
||||||
|
-- no video is playing and every frame must read dark; run with -v, where the picture is a moving
|
||||||
|
-- one and bright frames must appear.
|
||||||
|
dofile("Forge/AuthorCompile.singe")
|
||||||
|
|
||||||
|
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
|
||||||
|
|
||||||
|
fontSelect(font)
|
||||||
|
fontQuality(FONT_QUALITY_BLENDED)
|
||||||
|
|
||||||
|
local built = authorBuild("testScripts/author/picture.forge", singeGetDataPath() .. "picture.singe")
|
||||||
|
local file = built and io.open(singeGetDataPath() .. "picture.singe", "r")
|
||||||
|
local text = file and file:read("a") or ""
|
||||||
|
|
||||||
|
if file then
|
||||||
|
file:close()
|
||||||
|
end
|
||||||
|
if not built then
|
||||||
|
debugPrint("PICTURE FAIL the description did not compile")
|
||||||
|
singeQuit()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if text:find("authorPictureIs", 1, true) == nil then
|
||||||
|
debugPrint("PICTURE FAIL the built game does not call authorPictureIs")
|
||||||
|
end
|
||||||
|
if AUTHOR.behaviours.lightSensor == nil then
|
||||||
|
debugPrint("PICTURE FAIL the framework has no lightSensor behaviour")
|
||||||
|
end
|
||||||
|
dofile(singeGetDataPath() .. "picture.singe")
|
||||||
|
|
||||||
|
local gameUpdate = onOverlayUpdate
|
||||||
|
local frames = 0
|
||||||
|
local FRAMES = 120
|
||||||
|
|
||||||
|
|
||||||
|
-- The read on its own, beside the condition's: the runtime answers one of three words, and a
|
||||||
|
-- point outside the picture is still answered rather than failing.
|
||||||
|
local function checkRead()
|
||||||
|
local here = authorPictureState(200, 120)
|
||||||
|
local far = authorPictureState(-50, -50)
|
||||||
|
|
||||||
|
if (here ~= "dark") and (here ~= "bright") and (here ~= "unknown") then
|
||||||
|
debugPrint("PICTURE FAIL authorPictureState answered '" .. tostring(here) .. "'")
|
||||||
|
end
|
||||||
|
if far == nil then
|
||||||
|
debugPrint("PICTURE FAIL a point off the picture answered nothing")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
function onOverlayUpdate()
|
||||||
|
local result = gameUpdate and gameUpdate() or OVERLAY_UPDATED
|
||||||
|
local dark = AUTHOR_VARS.dark or 0
|
||||||
|
local bright = AUTHOR_VARS.bright or 0
|
||||||
|
local other = AUTHOR_VARS.neither or 0
|
||||||
|
|
||||||
|
frames = frames + 1
|
||||||
|
if frames == 1 then
|
||||||
|
checkRead()
|
||||||
|
end
|
||||||
|
overlayPrint(1, 1, string.format("pictureAt at 200,120: dark %d bright %d neither %d of %d frames", dark, bright, other, frames))
|
||||||
|
overlayPrint(1, 2, "lightSensor and pictureAt are the framework's own words now")
|
||||||
|
if frames == 60 then
|
||||||
|
singeScreenshot()
|
||||||
|
elseif frames == FRAMES then
|
||||||
|
debugPrint(string.format("PICTURE RESULT dark %d, bright %d, neither %d, of %d frames", dark, bright, other, frames))
|
||||||
|
if (dark + bright + other) ~= frames then
|
||||||
|
debugPrint("PICTURE FAIL the three answers do not add up to the frames drawn")
|
||||||
|
end
|
||||||
|
-- No video: the count of frames on the disc is zero, which is how a script tells.
|
||||||
|
if discGetFrameCount() == 0 then
|
||||||
|
if dark ~= frames then
|
||||||
|
debugPrint("PICTURE FAIL with no video playing every frame should read dark")
|
||||||
|
end
|
||||||
|
elseif (bright + other) == 0 then
|
||||||
|
-- Over a picture the read must see something other than the zeroes a missing video
|
||||||
|
-- gives. Which of bright and neither it sees is the video's business: the menu's
|
||||||
|
-- backdrop is mid-tone, so it reads neither and never bright.
|
||||||
|
debugPrint("PICTURE FAIL over a moving picture every frame still read dark")
|
||||||
|
end
|
||||||
|
singeQuit()
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
@ -149,13 +149,13 @@ def describe(folder, game):
|
||||||
% (lua("The last game's " + ident), lua(ident), lua(words)))
|
% (lua("The last game's " + ident), lua(ident), lua(words)))
|
||||||
out = []
|
out = []
|
||||||
out.append("-- %s, ported to Forge from ActionMax/Emulator.singe by util/forgePortActionMax.py: the" % TITLES[game])
|
out.append("-- %s, ported to Forge from ActionMax/Emulator.singe by util/forgePortActionMax.py: the" % TITLES[game])
|
||||||
out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the light sensor.")
|
out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the")
|
||||||
|
out.append("-- framework's own lightSensor behaviour.")
|
||||||
out.append("return {")
|
out.append("return {")
|
||||||
out.append("\ttitle = %s," % lua(TITLES[game]))
|
out.append("\ttitle = %s," % lua(TITLES[game]))
|
||||||
out.append("\tplayers = 1,")
|
out.append("\tplayers = 1,")
|
||||||
out.append("\tsize = { w = %d, h = %d }," % (WIDTH, HEIGHT))
|
out.append("\tsize = { w = %d, h = %d }," % (WIDTH, HEIGHT))
|
||||||
out.append("\tlayers = { { kind = \"disc\", file = %s }, { kind = \"overlay\" } }," % lua("frame_" + game + ".txt"))
|
out.append("\tlayers = { { kind = \"disc\", file = %s }, { kind = \"overlay\" } }," % lua("frame_" + game + ".txt"))
|
||||||
out.append("\tvocabulary = \"Vocabulary.singe\", -- The light sensor, these games' own (beside them).")
|
|
||||||
out.append("\tvars = { shots = 0, good = 0, bad = 0, ammo = 0, limited = false, beat = 0, scoreShown = 0, lightShown = 0 },")
|
out.append("\tvars = { shots = 0, good = 0, bad = 0, ammo = 0, limited = false, beat = 0, scoreShown = 0, lightShown = 0 },")
|
||||||
out.append("")
|
out.append("")
|
||||||
out.append("\ttypes = {")
|
out.append("\ttypes = {")
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,20 @@ end
|
||||||
env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir
|
env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir
|
||||||
assert(loadfile(dir .. "/" .. main, "t", env))()
|
assert(loadfile(dir .. "/" .. main, "t", env))()
|
||||||
|
|
||||||
|
-- The add-ons too, when the game has them: they carry the hooks the loop calls, and a hook that
|
||||||
|
-- is a function of the state can be read here rather than run at play (see swaps below). A file
|
||||||
|
-- that will not load under the stubs is no loss; the game still names it and the loop runs it.
|
||||||
|
local addonsFile = dir .. "/Script/addons.singe"
|
||||||
|
local addonsOk = false
|
||||||
|
|
||||||
|
do
|
||||||
|
local chunk = loadfile(addonsFile, "t", env)
|
||||||
|
|
||||||
|
if chunk then
|
||||||
|
addonsOk = pcall(chunk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- ===== The settings ===========================================================================
|
-- ===== The settings ===========================================================================
|
||||||
|
|
||||||
-- The dips, by name, from what the game was last saved with or shipped with: the framework
|
-- The dips, by name, from what the game was last saved with or shipped with: the framework
|
||||||
|
|
@ -329,6 +343,53 @@ local function scenesOf(level)
|
||||||
return by[0], nil
|
return by[0], nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A game's swapScene is a function of the difficulty, the level, and the scene: Tron plays a
|
||||||
|
-- different scene of five levels on the two easier settings. Nothing else goes into it, so every
|
||||||
|
-- answer can be read here and written as a table -- difficulty, then level, then the scene asked
|
||||||
|
-- for, giving the scene played -- and a game whose only hook is this one needs no Lua at play.
|
||||||
|
-- Only the answers that differ are kept, so a game that swaps nothing writes nothing.
|
||||||
|
local function swaps()
|
||||||
|
if type(env.swapScene) ~= "function" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
local found = false
|
||||||
|
|
||||||
|
for difficulty = 0, 3 do
|
||||||
|
local byLevel = {}
|
||||||
|
|
||||||
|
for level = 1, (env.finalstage or 0) do
|
||||||
|
local scenes = (env.Level[level] or {})[4] or 0
|
||||||
|
local byScene = {}
|
||||||
|
|
||||||
|
for scene = 1, scenes do
|
||||||
|
env.dip_Difficulty = difficulty
|
||||||
|
env.iLevel = level
|
||||||
|
env.iScene = scene
|
||||||
|
env.bSwap = false
|
||||||
|
local ok = pcall(env.swapScene)
|
||||||
|
|
||||||
|
if ok and (type(env.iScene) == "number") and (env.iScene ~= scene) then
|
||||||
|
byScene[scene] = env.iScene
|
||||||
|
found = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if next(byScene) then
|
||||||
|
byLevel[level] = byScene
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if next(byLevel) then
|
||||||
|
out[difficulty] = byLevel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
env.dip_Difficulty = nil
|
||||||
|
env.iLevel = nil
|
||||||
|
env.iScene = nil
|
||||||
|
env.bSwap = nil
|
||||||
|
|
||||||
|
return found and out or nil
|
||||||
|
end
|
||||||
|
|
||||||
-- ===== Writing ================================================================================
|
-- ===== Writing ================================================================================
|
||||||
|
|
||||||
local out = {}
|
local out = {}
|
||||||
|
|
@ -619,6 +680,7 @@ local qte = {
|
||||||
saves = kimmy and saves() or nil,
|
saves = kimmy and saves() or nil,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
dips = dips,
|
dips = dips,
|
||||||
|
swaps = (not rdg) and swaps() or nil,
|
||||||
highScores = rdg and RDG_BOARD or highScores(),
|
highScores = rdg and RDG_BOARD or highScores(),
|
||||||
tweaks = rdg and RDG_TWEAKS[name] or nil,
|
tweaks = rdg and RDG_TWEAKS[name] or nil,
|
||||||
sdq = sdq and sdqOrder() or nil,
|
sdq = sdq and sdqOrder() or nil,
|
||||||
|
|
@ -677,9 +739,11 @@ put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
|
||||||
put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },")
|
put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },")
|
||||||
put("")
|
put("")
|
||||||
put("\ttypes = {")
|
put("\ttypes = {")
|
||||||
-- A hand-written title's vocabulary draws its HUD as its own files do (the hud look); a
|
-- No look: the loops expose score, lives, credits, level and prompt as vars for a game's own
|
||||||
-- framework's is drawn by the runtime's look for that loop.
|
-- hud layer to bind, and draw none of the game's own screens. The hud and <loop>Hud looks this
|
||||||
put("\t\tgame = { look = { kind = \"" .. (alg and "hud" or (loop .. "Hud")) .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
|
-- line used to name belong to the drawing work the owner parked (FORGE.md 14.15) and are in no
|
||||||
|
-- manifest, so naming one only made the runtime complain about a look it does not have.
|
||||||
|
put("\t\tgame = { look = { kind = \"none\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
|
||||||
put("\t},")
|
put("\t},")
|
||||||
put("")
|
put("")
|
||||||
put("\trooms = {")
|
put("\trooms = {")
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,46 @@ ACTIONMAX = "ActionMax"
|
||||||
# and fonts (the loop draws its own text), the launcher art in Assets/ (the menu takes its
|
# and fonts (the loop draws its own text), the launcher art in Assets/ (the menu takes its
|
||||||
# pictures from Menu/, which games.dat names), and a Hypseus title's Structure/ (its framework).
|
# pictures from Menu/, which games.dat names), and a Hypseus title's Structure/ (its framework).
|
||||||
NOT_KARIS = {"KarisFramework", "MazescaterFramework", "games.dat", "Assets", "Structure"}
|
NOT_KARIS = {"KarisFramework", "MazescaterFramework", "games.dat", "Assets", "Structure"}
|
||||||
|
# A title proved to play from its description alone (util/forgeStandalone.py writes the list): its
|
||||||
|
# port carries none of the game's Lua, and the description in it names none. What goes with the
|
||||||
|
# script is the Script folder it kept its add-ons in and the Fonts and Overlay folders only the
|
||||||
|
# game's own drawing opened; the loop draws with the engine's font, and its eight sounds come from
|
||||||
|
# Sounds, so that stays.
|
||||||
|
STANDALONE_LIST = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "ports", "standalone.txt")
|
||||||
|
HOSTING = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$')
|
||||||
|
|
||||||
|
|
||||||
|
def standalone():
|
||||||
|
names = set()
|
||||||
|
if os.path.isfile(STANDALONE_LIST):
|
||||||
|
for line in open(STANDALONE_LIST):
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
names.add(line)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
STANDALONE = standalone()
|
||||||
|
|
||||||
|
|
||||||
|
def unhost(description):
|
||||||
|
# The description in the port, with the lines that name the game's Lua taken out.
|
||||||
|
if not os.path.isfile(description):
|
||||||
|
return
|
||||||
|
kept = [line for line in open(description, errors="replace").read().splitlines(True) if not HOSTING.match(line)]
|
||||||
|
with open(description, "w") as out:
|
||||||
|
out.writelines(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def hostedFiles(description):
|
||||||
|
# What a description names beside itself, so a standalone port can leave it behind: the
|
||||||
|
# game's script, and the folders that went with it.
|
||||||
|
text = open(description, errors="replace").read()
|
||||||
|
named = set(re.findall(r'^\s*(?:script|addons)\s*=\s*"([^"]+)"', text, re.M))
|
||||||
|
out = {"Fonts", "Overlay"}
|
||||||
|
for name in named:
|
||||||
|
out.add(name.split("/")[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def engine():
|
def engine():
|
||||||
|
|
@ -189,6 +229,8 @@ def main():
|
||||||
# A hand-written title (one with a vocabulary beside its description) loads its own
|
# A hand-written title (one with a vocabulary beside its description) loads its own
|
||||||
# fonts, which a framework title's port never does.
|
# fonts, which a framework title's port never does.
|
||||||
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
|
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
|
||||||
|
if name in STANDALONE:
|
||||||
|
leave |= hostedFiles(path)
|
||||||
placeTree(os.path.join(LIBRARY, name), folder, {"games.dat"} if inner else leave)
|
placeTree(os.path.join(LIBRARY, name), folder, {"games.dat"} if inner else leave)
|
||||||
if os.path.isfile(os.path.join(LIBRARY, name, "games.dat")):
|
if os.path.isfile(os.path.join(LIBRARY, name, "games.dat")):
|
||||||
dat = entries(os.path.join(LIBRARY, name, "games.dat"))
|
dat = entries(os.path.join(LIBRARY, name, "games.dat"))
|
||||||
|
|
@ -226,6 +268,8 @@ def main():
|
||||||
# A hand-written title (one with a vocabulary beside its description) loads its own
|
# A hand-written title (one with a vocabulary beside its description) loads its own
|
||||||
# fonts, which a framework title's port never does.
|
# fonts, which a framework title's port never does.
|
||||||
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
|
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
|
||||||
|
if title in STANDALONE:
|
||||||
|
leave |= hostedFiles(path)
|
||||||
placeTree(source, folder, leave | ({"singe", "Singe", "data", "Menu.sh", "MANIFEST.md"} if flat else set()), (".zip",) if flat else ())
|
placeTree(source, folder, leave | ({"singe", "Singe", "data", "Menu.sh", "MANIFEST.md"} if flat else set()), (".zip",) if flat else ())
|
||||||
# The framefile at the top of the title (with exactly the videos it names, loose beside
|
# The framefile at the top of the title (with exactly the videos it names, loose beside
|
||||||
# it or under Video/), or the one beside the script, already copied with the game.
|
# it or under Video/), or the one beside the script, already copied with the game.
|
||||||
|
|
@ -248,6 +292,12 @@ def main():
|
||||||
builds.append((os.path.join(folder, stem + ".forge"), os.path.join(folder, stem + ".port.singe")))
|
builds.append((os.path.join(folder, stem + ".forge"), os.path.join(folder, stem + ".port.singe")))
|
||||||
print("assembled %s (Hypseus)" % title)
|
print("assembled %s (Hypseus)" % title)
|
||||||
|
|
||||||
|
# A standalone port's description names no Lua, so the loop reads its snapshot instead of
|
||||||
|
# loading files that are no longer beside it.
|
||||||
|
for description, _ in builds:
|
||||||
|
if os.path.basename(os.path.dirname(description)) in STANDALONE or os.path.basename(os.path.dirname(os.path.dirname(description))) in STANDALONE:
|
||||||
|
unhost(description)
|
||||||
|
|
||||||
# The compiler is Forge's, so the engine runs it: every description built into its folder,
|
# The compiler is Forge's, so the engine runs it: every description built into its folder,
|
||||||
# from a directory with Forge beside Singe, never the repository.
|
# from a directory with Forge beside Singe, never the repository.
|
||||||
run = tempfile.mkdtemp(prefix="forgePorts.")
|
run = tempfile.mkdtemp(prefix="forgePorts.")
|
||||||
|
|
|
||||||
143
util/forgeStandalone.py
Normal file
143
util/forgeStandalone.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# Which Forge ports play from their description alone, with none of the game's own Lua?
|
||||||
|
#
|
||||||
|
# The qte and kimmy behaviours load only what the description names, so deleting its "script" and
|
||||||
|
# "addons" lines leaves the loop nothing but the snapshot the converter wrote. This runs each
|
||||||
|
# framework title that way, against its original, through the same comparison the ports are proved
|
||||||
|
# with (FORGE.md 14.1), and writes the ones that agree to testScripts/ports/standalone.txt.
|
||||||
|
# util/forgePorts.py reads that list and assembles those ports without the game's Lua.
|
||||||
|
#
|
||||||
|
# python3 util/forgeStandalone.py [<title> ...] (no arguments does them all)
|
||||||
|
# python3 util/forgeStandalone.py --hosted [<title> ...]
|
||||||
|
#
|
||||||
|
# --hosted runs each title as it ships instead, with its Lua where it is: the check to run after
|
||||||
|
# regenerating descriptions, since a converter that has learned something writes a description the
|
||||||
|
# loop reads differently. It writes no list.
|
||||||
|
#
|
||||||
|
# SINGE the engine binary (default: the harness picks the newest build)
|
||||||
|
# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported)
|
||||||
|
#
|
||||||
|
# A title is tested on a hard-linked copy of itself, so the library is never edited and the videos
|
||||||
|
# are not duplicated. A title whose description still needs its Lua stays off the list and keeps
|
||||||
|
# it: startConf drawing at random and specialScore's own scoring are code, not data.
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported"))
|
||||||
|
LIST = os.path.join(REPO, "testScripts", "ports", "standalone.txt")
|
||||||
|
HOSTED = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$')
|
||||||
|
|
||||||
|
|
||||||
|
def titles():
|
||||||
|
"""Every description the qte or kimmy behaviour plays, as (name, family, game argument,
|
||||||
|
description path). The library's own are a directory and a script; a Hypseus title is its
|
||||||
|
folder under hypseus/."""
|
||||||
|
found = []
|
||||||
|
for root, dirs, files in os.walk(LIBRARY):
|
||||||
|
for name in sorted(f for f in files if f.endswith(".forge")):
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
text = open(path, errors="replace").read()
|
||||||
|
loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text)
|
||||||
|
if not loop or loop.group(1) not in ("qte", "kimmy"):
|
||||||
|
continue
|
||||||
|
script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M)
|
||||||
|
if not script:
|
||||||
|
continue
|
||||||
|
relative = os.path.relpath(path, LIBRARY)
|
||||||
|
if relative.startswith("hypseus" + os.sep):
|
||||||
|
title = relative.split(os.sep)[1]
|
||||||
|
found.append((title, "hypseus", title, path))
|
||||||
|
else:
|
||||||
|
folder = relative.split(os.sep)[0]
|
||||||
|
found.append((folder, "karis", "%s/%s" % (folder, script.group(1)), path))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def strip(source, into):
|
||||||
|
"""The title, hard-linked so no video is copied, with its description's hosting lines gone.
|
||||||
|
The description itself is replaced rather than edited, since a hard link is shared."""
|
||||||
|
os.makedirs(into, exist_ok=True)
|
||||||
|
target = os.path.join(into, os.path.basename(source))
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
subprocess.run(["cp", "-al", source, target], check=True)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def unhost(descriptions):
|
||||||
|
for path in descriptions:
|
||||||
|
kept = [line for line in open(path, errors="replace").read().splitlines(True) if not HOSTED.match(line)]
|
||||||
|
os.remove(path) # The link, not the library's file.
|
||||||
|
with open(path, "w") as out:
|
||||||
|
out.writelines(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def compare(family, game, library):
|
||||||
|
done = subprocess.run([os.path.join(REPO, "testScripts", "ports", "compare.sh"), family, game],
|
||||||
|
cwd=REPO, env=dict(os.environ, SINGE_LIBRARY=library),
|
||||||
|
capture_output=True, text=True, timeout=1800)
|
||||||
|
out = done.stdout + done.stderr
|
||||||
|
if "plays as the original does" in out:
|
||||||
|
return True, [line for line in out.splitlines() if "plays as" in line][0]
|
||||||
|
first = [line for line in out.splitlines() if ("differ" in line) or ("Error" in line) or ("FAIL" in line)]
|
||||||
|
return False, first[0] if first else "no verdict"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
wanted = sys.argv[1:]
|
||||||
|
hosted = "--hosted" in wanted
|
||||||
|
|
||||||
|
if hosted:
|
||||||
|
wanted.remove("--hosted")
|
||||||
|
rows = [row for row in titles() if not wanted or row[0] in wanted]
|
||||||
|
if not rows:
|
||||||
|
sys.exit("forgeStandalone: no titles to test")
|
||||||
|
work = tempfile.mkdtemp(prefix="forgeStandalone.")
|
||||||
|
passed = []
|
||||||
|
|
||||||
|
for title, family, game, path in rows:
|
||||||
|
if hosted:
|
||||||
|
# As it ships: the library itself, nothing copied, nothing stripped.
|
||||||
|
ok, saying = compare(family, game, LIBRARY)
|
||||||
|
print("%-28s %s" % (title, "agrees" if ok else "DIFFERS"), flush=True)
|
||||||
|
print(" %s" % saying, flush=True)
|
||||||
|
if ok:
|
||||||
|
passed.append(title)
|
||||||
|
continue
|
||||||
|
# The title, and for a Hypseus one the hypseus/ folder the harness looks under.
|
||||||
|
if family == "hypseus":
|
||||||
|
library = os.path.join(work, "hypseus")
|
||||||
|
copied = strip(os.path.join(LIBRARY, "hypseus", title), library)
|
||||||
|
else:
|
||||||
|
library = work
|
||||||
|
copied = strip(os.path.join(LIBRARY, title), library)
|
||||||
|
inside = [os.path.join(r, f) for r, _, fs in os.walk(copied) for f in fs if f.endswith(".forge")]
|
||||||
|
unhost(inside)
|
||||||
|
ok, saying = compare(family, game, library)
|
||||||
|
print("%-28s %s" % (title, "STANDS ALONE" if ok else "needs its Lua"), flush=True)
|
||||||
|
print(" %s" % saying, flush=True)
|
||||||
|
if ok:
|
||||||
|
passed.append(title)
|
||||||
|
shutil.rmtree(copied, ignore_errors=True)
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
if hosted:
|
||||||
|
print("\n%d of %d agree as they ship" % (len(passed), len(rows)))
|
||||||
|
elif not wanted:
|
||||||
|
with open(LIST, "w") as out:
|
||||||
|
out.write("# Titles whose Forge port plays from its description alone, proved by\n")
|
||||||
|
out.write("# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua\n")
|
||||||
|
out.write("# out of these ports. Regenerate the list; do not edit it by hand.\n")
|
||||||
|
for title in sorted(passed):
|
||||||
|
out.write(title + "\n")
|
||||||
|
print("\n%d of %d stand alone; written to %s" % (len(passed), len(rows), LIST))
|
||||||
|
else:
|
||||||
|
print("\n%d of %d stand alone (the list is only written by a full run)" % (len(passed), len(rows)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -134,9 +134,9 @@ def boomShape(t, done):
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.makedirs(OUT, exist_ok=True)
|
os.makedirs(OUT, exist_ok=True)
|
||||||
save(ship(), "ship.png")
|
save(ship(), "spaceship.png")
|
||||||
save(rock(), "rock.png")
|
save(rock(), "asteroid.png")
|
||||||
save(shot(), "shot.png")
|
save(shot(), "bullet.png")
|
||||||
save(star(), "star.png")
|
save(star(), "star.png")
|
||||||
save(walk(), "walk.png")
|
save(walk(), "walk.png")
|
||||||
tone("shoot.wav", 0.18, shootShape)
|
tone("shoot.wav", 0.18, shootShape)
|
||||||
|
|
|
||||||
|
|
@ -134,4 +134,5 @@ def main():
|
||||||
print("%s: %d frames, %dx%d at %g fps, %.2f seconds" % (args.out, intro, args.width, args.height, 1000.0 / args.step, intro * args.step / 1000.0))
|
print("%s: %d frames, %dx%d at %g fps, %.2f seconds" % (args.out, intro, args.width, args.height, 1000.0 / args.step, intro * args.step / 1000.0))
|
||||||
|
|
||||||
|
|
||||||
main()
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
277
util/renderReel.py
Normal file
277
util/renderReel.py
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
# Renders a sizzle reel: the menu's intro with its sound, then a run of short shots of games and
|
||||||
|
# scenes, each with a caption, cut together as one H.264 file. Every shot is the engine itself
|
||||||
|
# running a script headless and photographing every frame, the way util/renderMenuAd.py records
|
||||||
|
# the intro, so what the reel shows is what the engine draws and nothing is mocked up.
|
||||||
|
#
|
||||||
|
# The shots are a list at the top of this file: a caption, a script (in the repo's testScripts,
|
||||||
|
# or a lesson from docs/learn), how many frames, and whether it needs the disc. Add a line to
|
||||||
|
# add a shot.
|
||||||
|
#
|
||||||
|
# The intro keeps its own sound. Nothing in the tree loops under the rest -- the backdrop
|
||||||
|
# recording has no audio and the menu's intro fades out by design -- so the shots are silent
|
||||||
|
# unless --music names a track. The engine's offscreen video driver stops at 1024x768, so anything larger records
|
||||||
|
# under a virtual X display (xvfb-run) on the software rasteriser, which is slow -- a frame is a
|
||||||
|
# second or two at 1080p -- and the same picture; a quick look at 1024x576 records on the real
|
||||||
|
# GPU in a fraction of the time.
|
||||||
|
#
|
||||||
|
# Usage: python3 util/renderReel.py (1920x1080 at 50 fps)
|
||||||
|
# python3 util/renderReel.py --width 1024 --height 576 --step 33 --out quick.mp4
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
OFFSCREEN_W = 1024 # The biggest the offscreen video driver gives; larger goes under Xvfb.
|
||||||
|
OFFSCREEN_H = 768
|
||||||
|
CRF = 15
|
||||||
|
AUDIO_BITRATE = "320k"
|
||||||
|
FONT = os.path.join(REPO, "assets", "FreeSansBold.ttf")
|
||||||
|
VIDEO = "Singe/menuBackground.mkv"
|
||||||
|
|
||||||
|
# The reel: what to show, in order. frames is how long each shot runs at the chosen step; at
|
||||||
|
# 20 ms a shot of 150 frames is three seconds. A shot with disc = True runs over the menu's
|
||||||
|
# backdrop video, which every install has. A shot names a script in testScripts or a lesson in
|
||||||
|
# docs/learn (the lesson folder layout the book teaches is built for it).
|
||||||
|
SHOTS = [
|
||||||
|
# The engine's own test scenes make the pictures; they write what they are checking over the
|
||||||
|
# top, which "clean" wipes away (see RUNNER). A 2D shot must not be cleaned: it draws into
|
||||||
|
# the overlay, and cleaning would wipe the game itself. Several scenes put the disc picture on
|
||||||
|
# a material and will not start without one, so they are given the menu's backdrop, which is
|
||||||
|
# the one video every install has.
|
||||||
|
{ "caption": "Sponza, lit and shadowed", "script": "testScripts/scene27.singe", "frames": 150, "clean": True },
|
||||||
|
{ "caption": "Sky, fog, and a sun that casts", "script": "testScripts/scene30.singe", "frames": 150, "clean": True },
|
||||||
|
{ "caption": "Bloom", "script": "testScripts/scene32.singe", "frames": 150, "clean": True },
|
||||||
|
{ "caption": "Terrain", "script": "testScripts/scene34.singe", "frames": 150, "clean": True },
|
||||||
|
{ "caption": "Water", "script": "testScripts/scene24.singe", "frames": 150, "clean": True, "disc": True },
|
||||||
|
{ "caption": "A bulb in a room", "script": "testScripts/scene21.singe", "frames": 150, "clean": True, "disc": True },
|
||||||
|
{ "caption": "Materials", "script": "testScripts/scene28.singe", "frames": 150, "clean": True },
|
||||||
|
{ "caption": "Physics", "script": "testScripts/scene9.singe", "frames": 150, "clean": True, "disc": True },
|
||||||
|
{ "caption": "Ragdolls", "script": "testScripts/scene25.singe", "frames": 150, "clean": True, "disc": True },
|
||||||
|
{ "caption": "Vehicles", "script": "testScripts/scene23.singe", "frames": 150, "clean": True, "disc": True },
|
||||||
|
{ "caption": "Particles", "script": "testScripts/scene18.singe", "frames": 150, "clean": True, "disc": True },
|
||||||
|
{ "caption": "Characters that find their own way","script": "testScripts/scene33.singe", "frames": 150, "clean": True },
|
||||||
|
{ "caption": "Animation, blended", "script": "testScripts/scene29.singe", "frames": 150, "clean": True },
|
||||||
|
# And what somebody makes with it: the lessons of the beginner's book, which are real games
|
||||||
|
# and are ours. These draw into the overlay, so they are never cleaned.
|
||||||
|
{ "caption": "Lesson seven: a game from nothing","lesson": "07-a-game", "frames": 150 },
|
||||||
|
{ "caption": "Lesson eleven: sprites and sound", "lesson": "11-hitting-things", "frames": 150 },
|
||||||
|
{ "caption": "Thirty lessons from nothing", "lesson": "22-3d", "frames": 150 },
|
||||||
|
]
|
||||||
|
|
||||||
|
# The runner every shot goes through: the shot's own script, untouched, with a frame counter that
|
||||||
|
# photographs each frame and quits when the shot is over.
|
||||||
|
RUNNER = '''-- Written by util/renderReel.py: runs the shot's own script and photographs every frame.
|
||||||
|
dofile(%(script)s)
|
||||||
|
|
||||||
|
local shotUpdate = onOverlayUpdate
|
||||||
|
local shotFrames = 0
|
||||||
|
|
||||||
|
|
||||||
|
function onOverlayUpdate()
|
||||||
|
local result = nil
|
||||||
|
|
||||||
|
if shotUpdate then
|
||||||
|
result = shotUpdate()
|
||||||
|
end
|
||||||
|
-- A test scene writes what it is checking over its own picture, which is right for a test and
|
||||||
|
-- wrong for a reel. A 3D scene is drawn by the engine underneath the overlay, so wiping the
|
||||||
|
-- overlay after the scene has had its turn leaves the picture and takes the notes away. Only
|
||||||
|
-- for shots that say so: a 2D scene draws INTO the overlay and would be wiped out with them.
|
||||||
|
if %(clean)s then
|
||||||
|
colorBackground(0, 0, 0, 0)
|
||||||
|
overlayClear()
|
||||||
|
end
|
||||||
|
singeScreenshot()
|
||||||
|
shotFrames = shotFrames + 1
|
||||||
|
if shotFrames >= %(frames)d then
|
||||||
|
singeQuit()
|
||||||
|
end
|
||||||
|
return OVERLAY_UPDATED
|
||||||
|
end
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# Where a shot's pictures actually landed. The engine files screenshots under
|
||||||
|
# <data directory>/<game name>/ when the script it was launched with sits in a folder of its own,
|
||||||
|
# and straight into the data directory when it does not; and it numbers from the first free name,
|
||||||
|
# which is not always zero. Answers the directory and the number to start at, or None.
|
||||||
|
def framesIn(folder):
|
||||||
|
places = [folder] + [os.path.join(folder, name) for name in sorted(os.listdir(folder))
|
||||||
|
if os.path.isdir(os.path.join(folder, name))]
|
||||||
|
for place in places:
|
||||||
|
numbers = sorted(int(m.group(1)) for m in
|
||||||
|
(re.match(r"^singe(\d+)\.png$", f) for f in os.listdir(place)) if m)
|
||||||
|
if numbers:
|
||||||
|
return place, numbers[0], len(numbers)
|
||||||
|
return None, 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
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 engine(binary, work, frames, width, height, step, script, disc):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["SDL_AUDIO_DRIVER"] = "dummy"
|
||||||
|
env["SDL_AUDIODRIVER"] = "dummy"
|
||||||
|
command = [binary, "-k", "-s", "-C", "%dx%d" % (width, height), "-x", str(width), "-y", str(height),
|
||||||
|
"--deterministic=%d" % step, "-d", frames + os.sep]
|
||||||
|
if disc:
|
||||||
|
command += ["-v", VIDEO]
|
||||||
|
command.append(script)
|
||||||
|
if (width > OFFSCREEN_W) or (height > OFFSCREEN_H):
|
||||||
|
command = ["xvfb-run", "-a", "-s", "-screen 0 %dx%dx24" % (width, height)] + command
|
||||||
|
else:
|
||||||
|
env["SDL_VIDEODRIVER"] = "offscreen"
|
||||||
|
result = subprocess.run(command, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=3600)
|
||||||
|
return result.returncode, result.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
# The intro, exactly as util/renderMenuAd.py records it, borrowed rather than copied.
|
||||||
|
def renderIntro(binary, work, frames, width, height, step):
|
||||||
|
sys.path.insert(0, os.path.join(REPO, "util"))
|
||||||
|
import renderMenuAd
|
||||||
|
with open(os.path.join(work, "render.singe"), "w") as out:
|
||||||
|
out.write(renderMenuAd.DRIVER % {"step": step, "width": width, "height": height})
|
||||||
|
text = renderMenuAd.render(binary, work, frames, width, height, step)
|
||||||
|
found = re.search(r"RENDER intro=(\d+) step=(\d+)", text)
|
||||||
|
if not found:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
raise SystemExit("the engine never said how long the intro is")
|
||||||
|
return int(found.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def setUpShot(work, shot, runnerName):
|
||||||
|
"""Lays the shot out as the engine expects and answers (what to dofile, what to launch), both
|
||||||
|
relative to the work directory. A lesson's runner goes INSIDE the lesson's folder: DIR is the
|
||||||
|
folder of the script the engine was launched with, and the lesson loads its art through DIR."""
|
||||||
|
if "lesson" in shot:
|
||||||
|
# A lesson runs from its own folder in a work folder, with the art kit beside it.
|
||||||
|
source = os.path.join(REPO, "docs", "learn")
|
||||||
|
folder = os.path.join(work, shot["lesson"])
|
||||||
|
shutil.rmtree(folder, ignore_errors=True)
|
||||||
|
os.makedirs(folder)
|
||||||
|
shutil.copyfile(os.path.join(source, shot["lesson"] + ".singe"), os.path.join(folder, shot["lesson"] + ".singe"))
|
||||||
|
if os.path.isdir(os.path.join(source, "art")):
|
||||||
|
shutil.copytree(os.path.join(source, "art"), os.path.join(folder, "art"))
|
||||||
|
for extra in os.listdir(source):
|
||||||
|
if os.path.isfile(os.path.join(source, extra)) and not extra.endswith(".singe"):
|
||||||
|
shutil.copyfile(os.path.join(source, extra), os.path.join(folder, extra))
|
||||||
|
return (shot["lesson"] + "/" + shot["lesson"] + ".singe",
|
||||||
|
shot["lesson"] + "/" + runnerName)
|
||||||
|
link = os.path.join(work, "testScripts")
|
||||||
|
if not os.path.islink(link):
|
||||||
|
os.symlink(os.path.join(REPO, "testScripts"), link)
|
||||||
|
return shot["script"], runnerName
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="render the sizzle reel")
|
||||||
|
parser.add_argument("--out", default=os.path.join(REPO, ".builddir", "SingeReel.mp4"))
|
||||||
|
parser.add_argument("--width", type=int, default=1920)
|
||||||
|
parser.add_argument("--height", type=int, default=1080)
|
||||||
|
parser.add_argument("--step", type=int, default=20, help="milliseconds a frame: 20 is 50 fps, 33 is 30.3")
|
||||||
|
parser.add_argument("--crf", type=int, default=CRF)
|
||||||
|
parser.add_argument("--binary", help="the engine to record with, in place of the built one")
|
||||||
|
parser.add_argument("--music", help="a track to play quietly under the shots; without one they are silent")
|
||||||
|
parser.add_argument("--musicLevel", type=float, default=0.25, help="how loud that track is, 1.0 being as recorded")
|
||||||
|
parser.add_argument("--only", type=int, help="record only this many shots after the intro, for a quick look")
|
||||||
|
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", "reel")
|
||||||
|
os.makedirs(work, exist_ok=True)
|
||||||
|
fps = 1000.0 / args.step
|
||||||
|
pieces = []
|
||||||
|
|
||||||
|
# The intro, with its own sound.
|
||||||
|
frames = os.path.join(work, "intro")
|
||||||
|
shutil.rmtree(frames, ignore_errors=True)
|
||||||
|
os.makedirs(frames)
|
||||||
|
print("intro")
|
||||||
|
introFrames = renderIntro(binary, work, frames, args.width, args.height, args.step)
|
||||||
|
where, first, got = framesIn(frames)
|
||||||
|
pieces.append({ "frames": where or frames, "first": first, "count": got or introFrames, "caption": None,
|
||||||
|
"sound": os.path.join(REPO, "assets", "menuIntro.flac") })
|
||||||
|
|
||||||
|
# The shots.
|
||||||
|
shots = SHOTS[:args.only] if args.only else SHOTS
|
||||||
|
for index, shot in enumerate(shots, 1):
|
||||||
|
frames = os.path.join(work, "shot%02d" % index)
|
||||||
|
shutil.rmtree(frames, ignore_errors=True)
|
||||||
|
os.makedirs(frames)
|
||||||
|
script, runner = setUpShot(work, shot, "reel%02d.singe" % index)
|
||||||
|
with open(os.path.join(work, runner), "w") as out:
|
||||||
|
out.write(RUNNER % {"script": '"%s"' % script, "frames": shot["frames"],
|
||||||
|
"clean": "true" if shot.get("clean") else "false"})
|
||||||
|
print("shot %d of %d: %s" % (index, len(shots), shot["caption"]))
|
||||||
|
code, text = engine(binary, work, frames, args.width, args.height, args.step, runner, shot.get("disc", False))
|
||||||
|
where, first, got = framesIn(frames)
|
||||||
|
if got == 0:
|
||||||
|
sys.stdout.write(text[-2000:])
|
||||||
|
raise SystemExit("shot %d recorded nothing" % index)
|
||||||
|
pieces.append({ "frames": where, "first": first, "count": got, "caption": shot["caption"], "sound": None })
|
||||||
|
|
||||||
|
# Each piece becomes a clip with its caption burnt in, then the clips are joined. The intro
|
||||||
|
# keeps its sound; the shots run under the intro's own tail, looped quietly, so the reel is
|
||||||
|
# never silent.
|
||||||
|
clips = []
|
||||||
|
for index, piece in enumerate(pieces):
|
||||||
|
clip = os.path.join(work, "clip%02d.mp4" % index)
|
||||||
|
command = ["ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-framerate", "1000/%d" % args.step, "-start_number", str(piece["first"]),
|
||||||
|
"-i", os.path.join(piece["frames"], "singe%03d.png")]
|
||||||
|
filters = ["format=yuv420p"]
|
||||||
|
if piece["caption"]:
|
||||||
|
text = piece["caption"].replace("'", "\\'").replace(":", "\\:")
|
||||||
|
filters.append("drawtext=fontfile='%s':text='%s':fontsize=%d:fontcolor=white:borderw=3:bordercolor=black@0.8:x=(w-text_w)/2:y=h-text_h-%d"
|
||||||
|
% (FONT, text, args.height // 18, args.height // 12))
|
||||||
|
command += ["-vf", ",".join(filters), "-c:v", "libx264", "-preset", "slow", "-crf", str(args.crf), "-profile:v", "high", "-an", clip]
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
clips.append(clip)
|
||||||
|
|
||||||
|
listing = os.path.join(work, "clips.txt")
|
||||||
|
with open(listing, "w") as out:
|
||||||
|
for clip in clips:
|
||||||
|
out.write("file '%s'\n" % clip)
|
||||||
|
silent = os.path.join(work, "reel-silent.mp4")
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", listing, "-c", "copy", silent], check=True)
|
||||||
|
|
||||||
|
# Sound: the intro's own, then whatever --music names, quietly, under the shots. Nothing in
|
||||||
|
# the tree loops: the backdrop recording has no audio at all, and the menu's intro fades out
|
||||||
|
# by design, so the shots are silent unless a track is given.
|
||||||
|
total = sum(p["count"] for p in pieces) * args.step / 1000.0
|
||||||
|
introSeconds = pieces[0]["count"] * args.step / 1000.0
|
||||||
|
bed = os.path.join(work, "bed.wav")
|
||||||
|
tail = max(0.0, total - introSeconds)
|
||||||
|
if args.music:
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-stream_loop", "-1", "-i", args.music,
|
||||||
|
"-vn", "-t", "%.3f" % tail, "-af", "volume=%g,afade=t=in:d=1,afade=t=out:st=%.3f:d=2" % (args.musicLevel, max(0.0, tail - 2.0)),
|
||||||
|
"-ar", "48000", "-ac", "2", bed], check=True)
|
||||||
|
else:
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
|
||||||
|
"-t", "%.3f" % tail, bed], check=True)
|
||||||
|
sound = os.path.join(work, "sound.wav")
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", pieces[0]["sound"], "-i", bed,
|
||||||
|
"-filter_complex", "[0:a]aformat=sample_rates=48000:channel_layouts=stereo[i];[1:a]adelay=%d|%d[b];[i][b]amix=inputs=2:duration=longest:dropout_transition=0,volume=2.0[a]" % (int(introSeconds * 1000), int(introSeconds * 1000)),
|
||||||
|
"-map", "[a]", "-t", "%.3f" % total, sound], check=True)
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", silent, "-i", sound,
|
||||||
|
"-c:v", "copy", "-c:a", "aac", "-b:a", AUDIO_BITRATE, "-movflags", "+faststart", "-shortest", args.out], check=True)
|
||||||
|
if not args.keep:
|
||||||
|
for piece in pieces:
|
||||||
|
shutil.rmtree(piece["frames"], ignore_errors=True)
|
||||||
|
print("%s: %d shots, %dx%d at %g fps, %.1f seconds" % (args.out, len(pieces) - 1, args.width, args.height, fps, total))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -139,6 +139,12 @@ def shoot(binary, renderer):
|
||||||
shots = os.path.join(work, "shots")
|
shots = os.path.join(work, "shots")
|
||||||
shutil.rmtree(shots, ignore_errors=True)
|
shutil.rmtree(shots, ignore_errors=True)
|
||||||
os.makedirs(shots)
|
os.makedirs(shots)
|
||||||
|
# Point the menu at a master service other than the real one -- a local instance while working
|
||||||
|
# on the online features -- by naming its URL in SINGE_MASTER_URL; the menu reads it from
|
||||||
|
# master.dat in its data directory, which this run has just emptied.
|
||||||
|
if os.environ.get("SINGE_MASTER_URL"):
|
||||||
|
with open(os.path.join(shots, "master.dat"), "w") as out:
|
||||||
|
out.write('{"url": "%s"}' % os.environ["SINGE_MASTER_URL"])
|
||||||
# The menu finds its games in the folders beside it: the test scripts' games.dat is enough.
|
# The menu finds its games in the folders beside it: the test scripts' games.dat is enough.
|
||||||
link = os.path.join(work, "testScripts")
|
link = os.path.join(work, "testScripts")
|
||||||
if not os.path.islink(link):
|
if not os.path.islink(link):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue