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
|
||||
-----------
|
||||
|
||||
- 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
|
||||
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
|
||||
|
|
@ -930,10 +973,9 @@ API Changes
|
|||
|
||||
- Player handles. The name on a leaderboard is a handle, not an email
|
||||
address: three to twenty characters of letters, digits, hyphens and
|
||||
underscores, chosen once. An operator can rename somebody; a player
|
||||
cannot, and an email address cannot be changed at all. Handles are
|
||||
checked against a list the operator maintains -- identities nobody
|
||||
should claim, and words with no place on a screen in a public room.
|
||||
underscores, chosen once. A player cannot change one afterwards, and an
|
||||
email address cannot be changed at all. A handle nobody should claim, or
|
||||
with no place on a screen in a public room, is refused when it is chosen.
|
||||
|
||||
- Online: accounts, a game catalogue, and high scores. A cabinet can sign
|
||||
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
|
||||
but not ranked.
|
||||
|
||||
A game the service withdraws keeps working on the cabinets that have it.
|
||||
All that stops is being offered an update, and the menu shows it as
|
||||
A game that stops being offered keeps working on the cabinets that have
|
||||
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.
|
||||
|
||||
scoreBegin() tells the service a play is starting, and the score that
|
||||
follows carries how long it took measured on the server's own clock.
|
||||
That is the one number in a submission that has not been through the
|
||||
player's machine. Nothing is rejected on it -- it is shown to the
|
||||
operator beside the score, and the judgement stays a person's.
|
||||
scoreBegin() says a play is starting, so the score that follows carries
|
||||
how long it took as timed at the other end rather than on this machine.
|
||||
It costs a game one call and nothing is refused on it.
|
||||
|
||||
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
|
||||
submissions, and flags outliers for a person rather than pretending to
|
||||
verify them.
|
||||
player's own machine, so nothing here pretends to verify one.
|
||||
|
||||
- Subtitles carried inside the video file: discGetSubtitleTracks(),
|
||||
discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads
|
||||
|
|
@ -994,6 +1032,51 @@ API Changes
|
|||
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
|
||||
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
|
||||
|
|
@ -1219,7 +1302,26 @@ New Features
|
|||
the overlay it is drawn on, so a larger recording gets a larger tagline
|
||||
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
|
||||
./build-docs.sh beside the manual and the Forge book. Thirty lessons that
|
||||
|
|
@ -1380,6 +1482,21 @@ New Features
|
|||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,28 @@
|
|||
|
||||
|
||||
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_REQUIRED ON)
|
||||
|
|
@ -253,7 +274,7 @@ set(MANUAL_DIR ${CMAKE_SOURCE_DIR}/.builddir)
|
|||
add_custom_command(
|
||||
OUTPUT ${MANUAL_DIR}/Manual.pdf
|
||||
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
|
||||
COMMENT "Rendering Manual.pdf"
|
||||
VERBATIM
|
||||
|
|
@ -315,19 +336,22 @@ file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/im
|
|||
add_custom_command(
|
||||
OUTPUT ${MANUAL_DIR}/Forge.pdf
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
|
||||
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
|
||||
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}
|
||||
COMMENT "Rendering Forge.pdf"
|
||||
VERBATIM
|
||||
)
|
||||
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)
|
||||
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
|
||||
COMMENT "Rendering Manual.html"
|
||||
COMMENT "Rendering Manual.html and Reference.html"
|
||||
VERBATIM
|
||||
)
|
||||
endif()
|
||||
|
|
@ -596,7 +620,7 @@ endif()
|
|||
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
|
||||
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
|
||||
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.
|
||||
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")
|
||||
|
||||
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
|
||||
BASISD_SUPPORT_KTX2=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 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 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 dialogues = {} -- Compiled dialogues by name, from authorDialogues.
|
||||
local talking = nil -- The dialogue on screen: { lines, choices, chosen }.
|
||||
|
|
@ -1546,6 +1548,58 @@ AUTHOR.behaviours.target = {
|
|||
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 = {
|
||||
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" },
|
||||
|
|
@ -2100,6 +2154,13 @@ AUTHOR.conditions.pointerOffscreen = {
|
|||
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 = {
|
||||
help = "An instance's box is at least partly on the overlay.",
|
||||
params = { entity = "entity" },
|
||||
|
|
@ -4379,6 +4440,29 @@ function authorPointerOffscreen(player)
|
|||
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
|
||||
-- behaviour, its look's box otherwise. nil when the track has no box now.
|
||||
function authorHitShape(instance)
|
||||
|
|
@ -5683,6 +5767,17 @@ function Q.setupLevel(q, thisLevel)
|
|||
q.iPathAend = 0
|
||||
if q.swapScene then
|
||||
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
|
||||
q.bFlip = false
|
||||
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
|
||||
if q.swapScene then
|
||||
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
|
||||
q.bFlip = false
|
||||
if q.iScene > level[QTE.TOTALSCENES] then
|
||||
|
|
|
|||
|
|
@ -1214,6 +1214,7 @@ end
|
|||
|
||||
|
||||
function forgeSave()
|
||||
forgeIdFor(FORGE.game)
|
||||
authorSave(FORGE.game, FORGE.path)
|
||||
FORGE.dirty = 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
|
||||
video = base .. "/" .. video
|
||||
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")
|
||||
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",
|
||||
title, base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
|
||||
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, forgeIdFor(FORGE.game), base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
|
||||
dat:close()
|
||||
end
|
||||
|
||||
|
|
@ -1318,6 +1322,94 @@ function forgeExport(folder, name)
|
|||
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
|
||||
-- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the
|
||||
-- folder and its name.
|
||||
|
|
@ -4070,6 +4162,8 @@ function forgeKey(keysym, scancode)
|
|||
forgeBuild(singeGetDataPath() .. "preview.singe")
|
||||
elseif scancode == SCANCODE.X.value then
|
||||
forgeExport(forgeReleaseFolder())
|
||||
elseif scancode == SCANCODE.P.value then
|
||||
forgePublish()
|
||||
elseif scancode == SCANCODE.U.value then
|
||||
forgeUndo()
|
||||
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
|
||||
-- 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
|
||||
-- on, which is the point; everything in it can be changed or deleted.
|
||||
function forgeTemplate()
|
||||
return {
|
||||
id = forgeIdFor({}),
|
||||
title = "New game",
|
||||
layers = { { kind = "world2d", gravity = 1500 } },
|
||||
vars = { score = 0 },
|
||||
|
|
@ -4944,6 +5064,10 @@ if not FORGE_LIBRARY then
|
|||
|
||||
|
||||
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
|
||||
forgeChooserDraw()
|
||||
else
|
||||
|
|
|
|||
|
|
@ -110,9 +110,12 @@ local function request(method, endpoint, body, onDone, opts)
|
|||
if MASTER.token and not opts.anonymous then
|
||||
headers["Authorization"] = "Bearer " .. MASTER.token
|
||||
end
|
||||
if opts.bodyFile then
|
||||
headers["Content-Type"] = opts.contentType or "application/octet-stream"
|
||||
end
|
||||
netRequest({
|
||||
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,
|
||||
}, function(ok, result)
|
||||
if not ok then
|
||||
|
|
@ -247,9 +250,84 @@ function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
|
|||
end
|
||||
|
||||
|
||||
function masterTellInstalled(gameId, version, onDone)
|
||||
request("POST", "/v1/installed", { game = gameId, version = version and math.floor(version) or nil },
|
||||
onDone or function() end)
|
||||
-- What this machine holds, and what is newer. The list is the menu's own, read off every
|
||||
-- games.dat it loaded (GAME_ID and VERSION), and the service keeps none of it: it answers which
|
||||
-- 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<div id="developer" class="line"></div>
|
||||
<div id="publisher" class="line"></div>
|
||||
<div id="port" class="line"></div>
|
||||
<div id="notice" class="line"></div>
|
||||
<div id="description"></div>
|
||||
</div>
|
||||
<div id="footer">
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ function loadGameAssets()
|
|||
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
|
||||
end
|
||||
MENU_RENDER.showGame(game)
|
||||
menuRefreshNotice()
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -186,6 +187,8 @@ function onInputPressed(what)
|
|||
else
|
||||
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
|
||||
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
|
||||
gameStart()
|
||||
end
|
||||
|
|
@ -337,6 +340,146 @@ for dir in lfs.dir(".") do
|
|||
end
|
||||
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
|
||||
debugPrint("No games found! Exiting.")
|
||||
singeQuit()
|
||||
|
|
@ -391,6 +534,8 @@ else
|
|||
dofile("Singe/MenuOverlay.singe")
|
||||
end
|
||||
MENU_RENDER.begin()
|
||||
menuCheckVersions()
|
||||
menuCheckRelease(false)
|
||||
MENU_RENDER.layout()
|
||||
MENU_RENDER.buildList()
|
||||
|
||||
|
|
|
|||
|
|
@ -260,7 +260,9 @@ MENU_RENDER.buildList = function()
|
|||
local rows = {}
|
||||
|
||||
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
|
||||
menuElement("list").inner_rml = table.concat(rows)
|
||||
for index = 1, GAME_COUNT do
|
||||
|
|
@ -269,6 +271,21 @@ MENU_RENDER.buildList = function()
|
|||
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)
|
||||
menuRow(GAME_SELECTED):SetClass("selected", true)
|
||||
scrollPending = true
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ local spriteCabinet = nil
|
|||
local spriteMarquee = nil
|
||||
local textSprites = {}
|
||||
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 textDown = true -- and which way it is walking.
|
||||
local textClock = 0
|
||||
|
|
@ -397,6 +398,18 @@ MENU_RENDER.buildList = function()
|
|||
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)
|
||||
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
|
||||
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
|
||||
-- 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)
|
||||
if menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
|
||||
title = title .. " [update]"
|
||||
end
|
||||
|
||||
colorForeground(0, 0, 0, 255)
|
||||
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
|
||||
-- and right move a page was to be told.
|
||||
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)
|
||||
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
|
||||
colorForeground(178, 196, 224, 255)
|
||||
|
|
|
|||
|
|
@ -150,7 +150,9 @@ local function buildRequest(request)
|
|||
for name, value in pairs(request.headers or {}) do
|
||||
lines[#lines + 1] = name .. ": " .. value
|
||||
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
|
||||
end
|
||||
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
|
||||
|
|
@ -221,10 +223,16 @@ local function pumpHandshake(request)
|
|||
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)
|
||||
if not request.outgoing then
|
||||
request.outgoing = buildRequest(request)
|
||||
end
|
||||
if request.sent < #request.outgoing then
|
||||
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
|
||||
|
||||
if sent then
|
||||
|
|
@ -234,8 +242,53 @@ local function pumpSending(request)
|
|||
else
|
||||
return fail(request, "send failed: " .. tostring(err))
|
||||
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"
|
||||
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
|
||||
|
||||
|
|
@ -442,9 +495,19 @@ function netRequest(opts, onDone)
|
|||
local request = {
|
||||
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
|
||||
method = opts.method or "GET", headers = opts.headers, body = opts.body,
|
||||
toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
|
||||
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(),
|
||||
bodyFile = opts.bodyFile, toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
|
||||
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
|
||||
active[request.id] = 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 =====
|
||||
|
||||
-- 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
|
||||
|
||||
|
||||
-- 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)
|
||||
-- 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
|
||||
return nil
|
||||
end
|
||||
return game.installed or 0
|
||||
return (MENU_VERSIONS and MENU_VERSIONS[game.id]) or 0
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -1271,6 +1477,45 @@ local function shopRefresh()
|
|||
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 game = shopGames[shopSelected]
|
||||
|
||||
|
|
@ -1282,35 +1527,16 @@ local function shopDownload()
|
|||
shopShow()
|
||||
return
|
||||
end
|
||||
local target = shopLocalName(game)
|
||||
local partial = target .. ".part"
|
||||
shopBusy = true
|
||||
shopProgress = "Starting..."
|
||||
shopShow()
|
||||
masterDownload(game.id, game.version, partial, game.sha256,
|
||||
function(got, total)
|
||||
shopProgress = 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)
|
||||
shopInstall(game,
|
||||
function(text)
|
||||
shopProgress = text
|
||||
end,
|
||||
function(ok, result)
|
||||
function(ok, text)
|
||||
shopBusy = false
|
||||
shopProgress = nil
|
||||
if not ok then
|
||||
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
|
||||
shopSaying = text
|
||||
shopShow()
|
||||
end)
|
||||
end
|
||||
|
|
@ -1326,10 +1552,9 @@ local function shopRemove()
|
|||
shopSaying = "That one is not installed here."
|
||||
else
|
||||
os.remove(shopLocalName(game))
|
||||
game.installed = nil
|
||||
shopSaying = game.title .. " removed. Its saved data is kept."
|
||||
if game.id then
|
||||
masterTellInstalled(game.id, nil)
|
||||
if MENU_VERSIONS and game.id then
|
||||
MENU_VERSIONS[game.id] = nil
|
||||
end
|
||||
end
|
||||
shopShow()
|
||||
|
|
@ -1341,6 +1566,8 @@ TOOLS[#TOOLS + 1] = {
|
|||
help = "Everything the service offers: download, update, remove.",
|
||||
|
||||
begin = function()
|
||||
-- Fresh word on what is newer than what is here, for the badges and for this page.
|
||||
menuCheckVersions()
|
||||
shopProgress = nil
|
||||
if masterSignedIn() and #shopGames == 0 then
|
||||
shopRefresh()
|
||||
|
|
|
|||
|
|
@ -115,6 +115,22 @@ body {
|
|||
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
|
||||
page taken out of view has to be said again here or its display wins. */
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
-- Cabinets and front ends ------------------------------------------------
|
||||
|
||||
-- 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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,100 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the Singe manual from docs/Manual.adoc, the Forge manual from docs/Forge.adoc, and the
|
||||
# beginner's course from docs/Learn.adoc. Produces .builddir/Manual.html, Manual.pdf, Forge.html,
|
||||
# Forge.pdf, Learn.html and Learn.pdf. The version comes from CMakeLists.txt.
|
||||
# Build the four books: the user manual (docs/Manual.adoc, which ships inside the binary), the
|
||||
# programming reference (docs/Reference.adoc), the Forge manual (docs/Forge.adoc), and the
|
||||
# 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
|
||||
|
||||
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
|
||||
forge=$here/docs/Forge.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
|
||||
echo "error: $doc not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
mkdir -p "$out"
|
||||
# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it.
|
||||
lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc"
|
||||
asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html"
|
||||
asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf"
|
||||
asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html"
|
||||
asciidoctor-pdf -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
|
||||
asciidoctor -a revnumber="$version" "$learn" -o "$out/Learn.html"
|
||||
asciidoctor-pdf -a revnumber="$version" "$learn" -o "$out/Learn.pdf"
|
||||
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.html"
|
||||
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.pdf"
|
||||
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.html"
|
||||
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
|
||||
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.html"
|
||||
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:"
|
||||
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)
|
||||
# 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.
|
||||
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(singeBinary ${SB_PREFIX}/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
|
||||
description format, the vocabulary, the compiler, and the editor. The engine
|
||||
calls the generated code makes (`playerNew`, `collidePointRect`,
|
||||
`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe Manual.
|
||||
`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe
|
||||
Reference.
|
||||
|
||||
== 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
|
||||
key or switch resolves the name with `authorKeyValue` or `authorSwitchValue`,
|
||||
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
|
||||
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
|
||||
`Vocabulary.singe` beside them and in no game that is not theirs.
|
||||
sets `instance.hearsAll` and reads them in `on`.
|
||||
|
||||
A word that turns out to be general belongs in the framework rather than
|
||||
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]
|
||||
----
|
||||
|
|
@ -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
|
||||
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
|
||||
`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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
| --
|
||||
|
||||
| `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`
|
||||
| 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)
|
||||
|
|
@ -430,6 +434,10 @@
|
|||
| Only the first time this rule would run -- per instance and per room unless scope is "game".
|
||||
| `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`
|
||||
| A player's pointer is over an instance.
|
||||
| `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
|
||||
read the Forge book, and this one does not build on it.
|
||||
|
||||
The _Singe Manual_ 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
|
||||
reaching for it around lesson six, and by the end of this book you will use it
|
||||
more than you use this book.
|
||||
The _Singe Reference_ is the reference: every function, every argument, every
|
||||
return value, and the programming model behind them. It is not a tutorial and
|
||||
does not try to be one. You will start reaching for it around lesson six, and
|
||||
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
|
||||
|
||||
|
|
|
|||
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 HIT_SCORE = 10
|
||||
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local starSprite = spriteLoad(DIR .. "art/star.png")
|
||||
|
||||
local screenWidth = overlayGetWidth()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ local STAR_COUNT = 40
|
|||
local START_LIVES = 3
|
||||
local HIT_SCORE = 10
|
||||
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local starSprite = spriteLoad(DIR .. "art/star.png")
|
||||
|
||||
local shootClip = soundLoad(DIR .. "art/shoot.wav")
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ local SHOT_HEIGHT = 10
|
|||
local SHOT_SPEED = 6
|
||||
local SHOW_BOXES = false
|
||||
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ local TITLE = "title"
|
|||
local PLAYING = "playing"
|
||||
local OVER = "over"
|
||||
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ local TITLE = "title"
|
|||
local PLAYING = "playing"
|
||||
local OVER = "over"
|
||||
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ singeSetPauseKeyEnabled(false)
|
|||
|
||||
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 shipX = 0
|
||||
local speed = 2
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ local SHIP_HEIGHT = 24
|
|||
local SHIP_SPEED = 120
|
||||
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 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
|
||||
will find these files where they say they are.
|
||||
|
||||
ship.png 32 x 24 A ship pointing up.
|
||||
rock.png 24 x 24 An asteroid.
|
||||
shot.png 4 x 10 A bullet.
|
||||
spaceship.png 32 x 24 A ship pointing up.
|
||||
asteroid.png 24 x 24 An asteroid.
|
||||
bullet.png 4 x 10 A bullet.
|
||||
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.
|
||||
shoot.wav A shot.
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ first thing beginners get wrong.
|
|||
`onOverlayUpdate`, which asks "is the player holding left?" sixty times a
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
what each one has to be. Singe checks them because the alternative is a game
|
||||
that runs for ten minutes and then draws garbage.
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ singe/ <- your work folder
|
|||
rocks/
|
||||
rocks.singe
|
||||
art/
|
||||
ship.png
|
||||
rock.png
|
||||
shot.png
|
||||
spaceship.png
|
||||
asteroid.png
|
||||
bullet.png
|
||||
star.png
|
||||
walk.png
|
||||
shoot.wav
|
||||
|
|
@ -93,7 +93,7 @@ Start a new file next to your `art` folder and type this in.
|
|||
----
|
||||
dofile("Singe/Framework.singe")
|
||||
|
||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
||||
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||
|
||||
|
||||
function onOverlayUpdate()
|
||||
|
|
@ -116,7 +116,7 @@ Four new things, one at a time.
|
|||
|
||||
[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
|
||||
|
|
@ -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
|
||||
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
|
||||
from your work folder, so a plain `art/ship.png` sends the engine looking for
|
||||
`singe/art/ship.png`, out beside the `Singe` folder. Your artwork is not
|
||||
there. It is one level down, in `rocks/art/ship.png`, with your script.
|
||||
from your work folder, so a plain `art/spaceship.png` sends the engine looking for
|
||||
`singe/art/spaceship.png`, out beside the `Singe` folder. Your artwork is not
|
||||
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
|
||||
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 --
|
||||
names the picture correctly however the game was started, and keeps naming it
|
||||
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
|
||||
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.
|
||||
|
||||
[source,lua]
|
||||
|
|
@ -227,7 +227,7 @@ reasonable:
|
|||
----
|
||||
function onOverlayUpdate()
|
||||
overlayClear()
|
||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
||||
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||
spriteDraw(ship, 160, 100)
|
||||
return OVERLAY_UPDATED
|
||||
end
|
||||
|
|
@ -358,7 +358,7 @@ leftmost figure and frame 4 the rightmost.
|
|||
|
||||
Watch out for that 1. Elsewhere in the sprite family -- `spriteSetFrame` and
|
||||
`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.
|
||||
|
||||
`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 HIT_SCORE = 10
|
||||
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local starSprite = spriteLoad(DIR .. "art/star.png")
|
||||
|
||||
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
|
||||
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
|
||||
and the one the manual documents.
|
||||
and the one the reference documents.
|
||||
|
||||
=== Try It
|
||||
|
||||
|
|
@ -712,7 +712,7 @@ operating system's own wording and will differ slightly from machine to
|
|||
machine.
|
||||
|
||||
`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
|
||||
would have worked, which is worse, because it means the bug travels to
|
||||
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
|
||||
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:
|
||||
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.
|
||||
|
||||
=== 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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
=== What You Learned
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ this, with the details of the rocks left out:
|
|||
|
||||
[source,lua]
|
||||
----
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shootSound = soundLoad(DIR .. "art/shoot.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:
|
||||
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
|
||||
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
|
||||
no width or height touches nothing at all.
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ number rather than by hunting through your tests.
|
|||
|
||||
=== 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:
|
||||
|
||||
[source,lua]
|
||||
|
|
@ -160,7 +160,7 @@ local SHOT_WIDTH = 4
|
|||
local SHOT_HEIGHT = 10
|
||||
local SHOT_SPEED = 6
|
||||
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
=== Try It
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ Rocks/
|
|||
games.dat What the menu reads
|
||||
Rocks.singe The game
|
||||
art/
|
||||
ship.png
|
||||
rock.png
|
||||
shot.png
|
||||
spaceship.png
|
||||
asteroid.png
|
||||
bullet.png
|
||||
star.png
|
||||
shoot.wav
|
||||
boom.wav
|
||||
|
|
@ -72,7 +72,7 @@ one.
|
|||
|
||||
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
|
||||
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
|
||||
|
|
@ -85,9 +85,9 @@ your file from wherever Singe was started:
|
|||
|
||||
[source,lua]
|
||||
----
|
||||
local shipSprite = spriteLoad(DIR .. "art/ship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/rock.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/shot.png")
|
||||
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
|
||||
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
|
||||
local shootSound = soundLoad(DIR .. "art/shoot.wav")
|
||||
local boomSound = soundLoad(DIR .. "art/boom.wav")
|
||||
----
|
||||
|
|
@ -220,7 +220,7 @@ again tomorrow.
|
|||
|
||||
[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
|
||||
|
|
@ -267,19 +267,19 @@ else's.
|
|||
|
||||
=== 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
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
The file it wanted is one folder further down, which is exactly what `DIR`
|
||||
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
|
||||
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
|
||||
`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.
|
||||
|
||||
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
|
||||
plays forever until somebody presses a button.
|
||||
. *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.
|
||||
. *Play it without the video.* Run `Singe -R movie` and then press
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
=== 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
|
||||
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
|
||||
in the manual under `discGetState`.
|
||||
in the reference under `discGetState`.
|
||||
|
||||
[source,lua]
|
||||
----
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ white, this is why.
|
|||
|
||||
=== 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
|
||||
rasterizes the whole string from the font outlines, builds an image of it,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ dofile("Singe/Framework.singe")
|
|||
|
||||
overlaySetResolution(discGetWidth(), discGetHeight())
|
||||
|
||||
local ship = spriteLoad(DIR .. "art/ship.png")
|
||||
local ship = spriteLoad(DIR .. "art/spaceship.png")
|
||||
local shipX = 0
|
||||
local speed = 2
|
||||
local score = 0
|
||||
|
|
@ -211,7 +211,7 @@ that might not be there without dying.
|
|||
|
||||
That is the whole everyday API: `guiSetHandler`, `guiSetValue`, `guiGetValue`.
|
||||
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.
|
||||
|
||||
=== Hide It Again
|
||||
|
|
@ -341,7 +341,7 @@ singeSetPauseKeyEnabled(false)
|
|||
|
||||
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 shipX = 0
|
||||
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
|
||||
`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
|
||||
some of them; the manual's Material section lists them all.
|
||||
some of them; the reference's Material section lists them all.
|
||||
|
||||
[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.
|
||||
. *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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ exactly one.
|
|||
`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.
|
||||
`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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
=== 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
that slides in wants a tween.
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ local SHIP_WIDTH = 32
|
|||
local SHIP_HEIGHT = 24
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
=== 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
|
||||
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
|
||||
the manual's entry for it explains when you want it.
|
||||
the reference's entry for it explains when you want it.
|
||||
|
||||
[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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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`,
|
||||
and `DIR .. "art/ship.png"` all find the same file.
|
||||
equal to the game's own is ignored, so `MyGame/art/spaceship.png`, `art/spaceship.png`,
|
||||
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
|
||||
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
|
||||
----
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ startsilent = true
|
|||
`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.
|
||||
`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.
|
||||
|
||||
=== 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
|
||||
that credits do not go negative.
|
||||
. *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.
|
||||
. *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
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
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 <sys/stat.h>
|
||||
#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 <io.h>
|
||||
#include <process.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
|
@ -70,7 +72,8 @@
|
|||
|
||||
|
||||
#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 MIXER_FREQUENCY 44100
|
||||
#define MIXER_CHANNELS 2
|
||||
|
|
@ -131,6 +134,8 @@ typedef enum LongOptionE {
|
|||
OPT_MAPJOYSTICKS,
|
||||
OPT_MONOCHROME,
|
||||
OPT_NOGAMEPAD,
|
||||
OPT_NO_UPDATE_CHECK,
|
||||
OPT_REFRESH,
|
||||
OPT_SCREEN,
|
||||
OPT_SOUNDFONT,
|
||||
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_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_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_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 },
|
||||
|
|
@ -290,6 +297,7 @@ static bool _parseBoolean(const char *text, bool *value);
|
|||
static bool _parseFloat(const char *text, double *value);
|
||||
static bool _parseGamepadOrder(const char *text);
|
||||
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 _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what);
|
||||
static void _resolveFiles(const char *exeName, ConfigT *conf);
|
||||
|
|
@ -301,7 +309,63 @@ static void _startSDL(void);
|
|||
static void _stopSDL(void);
|
||||
static void _stripDatabaseNames(char *path);
|
||||
static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device);
|
||||
static void _unpackData(const char *exePath, bool absolute);
|
||||
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
|
||||
|
|
@ -685,6 +749,16 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons
|
|||
conf->noGamepad = true;
|
||||
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
|
||||
case OPT_SCREEN:
|
||||
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[] = {
|
||||
{ "Framework.singe", Framework_singe, Framework_singe_len },
|
||||
{ "controls.cfg.example", controls_cfg, controls_cfg_len },
|
||||
|
|
@ -1770,6 +1892,11 @@ static void _unpackData(const char *exePath, bool absolute) {
|
|||
bool created = false;
|
||||
|
||||
// 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)) {
|
||||
utilDie("Unable to create %s directory.", VFS_ENGINE_DIRECTORY);
|
||||
}
|
||||
|
|
@ -1780,6 +1907,14 @@ static void _unpackData(const char *exePath, bool absolute) {
|
|||
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
|
||||
// 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
|
||||
|
|
@ -1790,7 +1925,73 @@ static void _unpackData(const char *exePath, bool 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);
|
||||
} 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 {
|
||||
// Winders
|
||||
|
|
@ -1798,7 +1999,86 @@ static void _unpackData(const char *exePath, bool 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);
|
||||
} 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));
|
||||
|
|
@ -2093,6 +2373,7 @@ int main(int argc, char *argv[]) {
|
|||
ConfigT *replacement = NULL;
|
||||
QueueT *q = NULL;
|
||||
bool ok = false;
|
||||
bool relaunched = false;
|
||||
|
||||
#ifndef _WIN32
|
||||
signal(SIGSEGV, _crashHandler);
|
||||
|
|
@ -2125,8 +2406,8 @@ int main(int argc, char *argv[]) {
|
|||
}
|
||||
}
|
||||
|
||||
_unpackData(exePath, conf->gameDir != NULL);
|
||||
free(exePath);
|
||||
_unpackData(exePath, conf->gameDir != NULL, conf->refreshSupport);
|
||||
_executable = exePath;
|
||||
|
||||
// -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).
|
||||
|
|
@ -2198,7 +2479,18 @@ int main(int argc, char *argv[]) {
|
|||
free(_commandLine);
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ bool isFrameFileName(const char *filename);
|
|||
char *mainDescribeAudioDecoders(void);
|
||||
char *mainDescribeCpu(void);
|
||||
char *mainDescribeOs(void);
|
||||
const char *mainExecutablePath(void);
|
||||
bool parseSindenString(const char *sindenString, ConfigT *conf);
|
||||
void queueScript(const ConfigT *conf);
|
||||
char *resolveDataDir(const ConfigT *conf);
|
||||
|
|
|
|||
63
src/pack.c
63
src/pack.c
|
|
@ -68,6 +68,9 @@ typedef struct WriterS {
|
|||
} 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 _badExtensions[] = { "exe", "sh", "bat", "cmd", 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 _writeBlob(FILE *file, const void *data, size_t bytes);
|
||||
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 _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) {
|
||||
bool ok = true;
|
||||
|
||||
|
|
@ -781,6 +833,17 @@ bool packPatch(const char *database, const char *source) {
|
|||
if (!_writerClose(&writer, ok)) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
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 "midi.h"
|
||||
#include "midiIo.h"
|
||||
#include "pack.h"
|
||||
#include "util.h"
|
||||
#include "frameFile.h"
|
||||
#include "vfs.h"
|
||||
|
|
@ -561,6 +562,8 @@ typedef struct GlobalS {
|
|||
SDL_Renderer *renderer;
|
||||
SDL_GPUDevice *device;
|
||||
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;
|
||||
int32_t watchedCount;
|
||||
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 apiSingeGetWidth(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 apiSingeRelaunch(lua_State *L);
|
||||
static int32_t apiSingeReload(lua_State *L);
|
||||
static int32_t apiSingeScreenshot(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_pushboolean(L, _global.conf->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, "singeGetWidth", apiSingeGetWidth); // 1.xx
|
||||
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, "singeRelaunch", apiSingeRelaunch); // 3.00
|
||||
lua_register(L, "singeReload", apiSingeReload); // 3.00
|
||||
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
|
||||
lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx
|
||||
|
|
@ -13516,6 +13525,8 @@ static int32_t apiSingeGetSystemInfo(lua_State *L) {
|
|||
lua_newtable(L);
|
||||
lua_pushstring(L, VERSION_STRING); lua_setfield(L, -2, "version");
|
||||
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, 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");
|
||||
|
|
@ -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) {
|
||||
_luaTrace(L, "singeQuit", "Quit requested.");
|
||||
_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
|
||||
static int32_t apiSingeReload(lua_State *L) {
|
||||
_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 softwareVideo; // Skip the platform's hardware decoder
|
||||
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 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
|
||||
|
|
@ -163,6 +165,8 @@ ConfigT *confFromGamesDat(const ConfigT *conf);
|
|||
void settingsFree(SettingT *settings, int32_t count);
|
||||
SettingT *settingsLoad(const ConfigT *conf, int32_t *count);
|
||||
void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf);
|
||||
const char *singeRelaunchExtra(void);
|
||||
const char *singeRelaunchPath(void);
|
||||
|
||||
|
||||
#endif // SINGE_H
|
||||
|
|
|
|||
|
|
@ -28,10 +28,14 @@
|
|||
// ===== THIS FILE IS AUTOMATICALLY GENERATED FROM version.h.in - DO NOT EDIT =====
|
||||
// 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_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@"
|
||||
|
||||
|
||||
|
|
|
|||
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)))
|
||||
out = []
|
||||
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("\ttitle = %s," % lua(TITLES[game]))
|
||||
out.append("\tplayers = 1,")
|
||||
out.append("\tsize = { w = %d, h = %d }," % (WIDTH, HEIGHT))
|
||||
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("")
|
||||
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
|
||||
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 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
|
||||
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 ================================================================================
|
||||
|
||||
local out = {}
|
||||
|
|
@ -619,6 +680,7 @@ local qte = {
|
|||
saves = kimmy and saves() or nil,
|
||||
settings = settings,
|
||||
dips = dips,
|
||||
swaps = (not rdg) and swaps() or nil,
|
||||
highScores = rdg and RDG_BOARD or highScores(),
|
||||
tweaks = rdg and RDG_TWEAKS[name] 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("")
|
||||
put("\ttypes = {")
|
||||
-- A hand-written title's vocabulary draws its HUD as its own files do (the hud look); a
|
||||
-- framework's is drawn by the runtime's look for that loop.
|
||||
put("\t\tgame = { look = { kind = \"" .. (alg and "hud" or (loop .. "Hud")) .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
|
||||
-- No look: the loops expose score, lives, credits, level and prompt as vars for a game's own
|
||||
-- hud layer to bind, and draw none of the game's own screens. The hud and <loop>Hud looks this
|
||||
-- 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("")
|
||||
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
|
||||
# pictures from Menu/, which games.dat names), and a Hypseus title's Structure/ (its framework).
|
||||
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():
|
||||
|
|
@ -189,6 +229,8 @@ def main():
|
|||
# A hand-written title (one with a vocabulary beside its description) loads its own
|
||||
# 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())
|
||||
if name in STANDALONE:
|
||||
leave |= hostedFiles(path)
|
||||
placeTree(os.path.join(LIBRARY, name), folder, {"games.dat"} if inner else leave)
|
||||
if os.path.isfile(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
|
||||
# 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())
|
||||
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 ())
|
||||
# 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.
|
||||
|
|
@ -248,6 +292,12 @@ def main():
|
|||
builds.append((os.path.join(folder, stem + ".forge"), os.path.join(folder, stem + ".port.singe")))
|
||||
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,
|
||||
# from a directory with Forge beside Singe, never the repository.
|
||||
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():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
save(ship(), "ship.png")
|
||||
save(rock(), "rock.png")
|
||||
save(shot(), "shot.png")
|
||||
save(ship(), "spaceship.png")
|
||||
save(rock(), "asteroid.png")
|
||||
save(shot(), "bullet.png")
|
||||
save(star(), "star.png")
|
||||
save(walk(), "walk.png")
|
||||
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))
|
||||
|
||||
|
||||
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")
|
||||
shutil.rmtree(shots, ignore_errors=True)
|
||||
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.
|
||||
link = os.path.join(work, "testScripts")
|
||||
if not os.path.islink(link):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue